diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index ca01fc5..d798337 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -1,90 +1,73 @@ -# This workflows will upload a Python Package using Twine when a release is created -# For more information see: https://help.github.com/en/actions/language-and-framework-guides/using-python-with-github-actions#publishing-to-package-registries - name: tests on: push: branches: - main - - main tags: - v[0-9]+.[0-9]+.[0-9]+ - v[0-9]+.[0-9]+.[0-9]+-dev[0-9]+ pull_request: branches: - main - - main workflow_dispatch: jobs: test: name: ${{ matrix.platform }} py${{ matrix.python-version }} runs-on: ${{ matrix.platform }} + strategy: matrix: - # platform: [ubuntu-latest, windows-latest, macos-latest] platform: [ubuntu-latest] - python-version: [3.8, 3.9, "3.10"] + python-version: ["3.9", "3.10", "3.11"] steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 - - name: Set up Python ${{ matrix.python-version }} - uses: actions/setup-python@v2 + - name: Set up Python + uses: actions/setup-python@v5 with: python-version: ${{ matrix.python-version }} - # these libraries enable testing on Qt on linux - uses: tlambert03/setup-qt-libs@v1 - # strategy borrowed from vispy for installing opengl libs on windows - - name: Install Windows OpenGL - if: runner.os == 'Windows' - run: | - git clone --depth 1 https://github.com/pyvista/gl-ci-helpers.git - powershell gl-ci-helpers/appveyor/install_opengl.ps1 - # note: if you need dependencies from conda, considering using - # setup-miniconda: https://github.com/conda-incubator/setup-miniconda - # and - # tox-conda: https://github.com/tox-dev/tox-conda - name: Install dependencies run: | python -m pip install --upgrade pip - python -m pip install setuptools tox tox-gh-actions - # this runs the platform-specific tests declared in tox.ini + python -m pip install tox tox-gh-actions + - name: Test with tox uses: GabrielBB/xvfb-action@v1 with: run: python -m tox - env: - PLATFORM: ${{ matrix.platform }} - name: Coverage - uses: codecov/codecov-action@v2 + uses: codecov/codecov-action@v4 deploy: - # this will run when you have tagged a commit, starting with "v*" - # and requires that you have put your twine API key in your - # github secrets (see readme for details) needs: [test] runs-on: ubuntu-latest if: contains(github.ref, 'tags') + steps: - - uses: actions/checkout@v2 + - uses: actions/checkout@v4 + - name: Set up Python - uses: actions/setup-python@v2 + uses: actions/setup-python@v5 with: python-version: "3.x" - - name: Install dependencies + + - name: Install build tools run: | python -m pip install --upgrade pip - pip install -U setuptools setuptools_scm wheel twine - - name: Build and publish + pip install build twine + + - name: Build package + run: python -m build + + - name: Publish to PyPI env: TWINE_USERNAME: __token__ TWINE_PASSWORD: ${{ secrets.TWINE_API_KEY }} - run: | - git tag - python setup.py sdist bdist_wheel - twine upload dist/* + run: twine upload dist/* \ No newline at end of file diff --git a/.gitignore b/.gitignore index 1f12262..51f27ad 100644 --- a/.gitignore +++ b/.gitignore @@ -5,13 +5,15 @@ __pycache__/ # C extensions *.so +*.pyd # Distribution / packaging .Python env/ +venv/ build/ -develop-eggs/ dist/ +develop-eggs/ downloads/ eggs/ .eggs/ @@ -24,55 +26,35 @@ var/ .installed.cfg *.egg -# PyInstaller -# Usually these files are written by a python script from a template -# before PyInstaller builds the exe, so as to inject date/other infos into it. -*.manifest -*.spec - # Installer logs pip-log.txt pip-delete-this-directory.txt -# Unit test / coverage reports +# Unit test / coverage htmlcov/ .tox/ .coverage .coverage.* .cache +.pytest_cache/ +.mypy_cache/ +.ruff_cache/ nosetests.xml coverage.xml *,cover .hypothesis/ .napari_cache -# Translations -*.mo -*.pot - -# Django stuff: -*.log -local_settings.py - -# Flask instance folder -instance/ - -# Sphinx documentation +# Documentation docs/_build/ +site/ -# MkDocs documentation -/site/ - -# PyBuilder -target/ - -# Pycharm and VSCode +# IDE .idea/ -venv/ .vscode/ -# IPython Notebook -.ipynb_checkpoints +# Jupyter +.ipynb_checkpoints/ # pyenv .python-version @@ -80,7 +62,5 @@ venv/ # OS .DS_Store -# written by setuptools_scm -**/_version.py - -scratch/ +# Misc +scratch/ \ No newline at end of file diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index 6c9ecb8..9b7bed5 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -1,42 +1,53 @@ repos: - repo: https://github.com/pre-commit/pre-commit-hooks - rev: v4.0.1 + rev: v4.6.0 hooks: - id: check-docstring-first - id: end-of-file-fixer - id: trailing-whitespace - - repo: https://github.com/asottile/setup-cfg-fmt - rev: v1.20.0 - hooks: - - id: setup-cfg-fmt + - id: check-yaml + - repo: https://github.com/PyCQA/flake8 - rev: 4.0.1 + rev: 7.1.1 hooks: - id: flake8 - additional_dependencies: [flake8-typing-imports==1.7.0] - - repo: https://github.com/myint/autoflake - rev: v1.4 - hooks: - - id: autoflake - args: ["--in-place", "--remove-all-unused-imports"] + additional_dependencies: + - flake8-typing-imports==1.16.0 + - repo: https://github.com/PyCQA/isort - rev: 5.10.1 + rev: 5.13.2 hooks: - id: isort + - repo: https://github.com/psf/black - rev: 22.3.0 + rev: 24.10.0 hooks: - id: black + - repo: https://github.com/asottile/pyupgrade - rev: v2.29.1 + rev: v3.19.1 hooks: - id: pyupgrade - args: [--py37-plus, --keep-runtime-typing] + args: [--py38-plus, --keep-runtime-typing] + + - repo: https://github.com/PyCQA/autoflake + rev: v2.3.1 + hooks: + - id: autoflake + args: + - --in-place + - --remove-all-unused-imports + - --remove-unused-variables + exclude: ^src/napari_basicpy/_version\.py$ + - repo: https://github.com/tlambert03/napari-plugin-checks - rev: v0.2.0 + rev: v0.3.0 hooks: - id: napari-plugin-checks + - repo: https://github.com/pre-commit/mirrors-mypy - rev: v0.910-1 + rev: v1.11.2 hooks: - id: mypy + additional_dependencies: [] + exclude: ^src/napari_basicpy/_version\.py$ \ No newline at end of file diff --git a/MANIFEST.in b/MANIFEST.in index 0439384..62e9e99 100644 --- a/MANIFEST.in +++ b/MANIFEST.in @@ -2,5 +2,7 @@ include LICENSE include README.md include requirements.txt +recursive-include src *.yaml + recursive-exclude * __pycache__ -recursive-exclude * *.py[co] +recursive-exclude * *.py[co] \ No newline at end of file diff --git a/README.md b/README.md index c44cd53..cd9c7e3 100644 --- a/README.md +++ b/README.md @@ -1,10 +1,10 @@ # napari-basicpy -[![License](https://img.shields.io/pypi/l/napari-basicpy.svg?color=green)](https://github.com/tdmorello/napari-basicpy/raw/main/LICENSE) +[![License](https://img.shields.io/pypi/l/napari-basicpy.svg?color=green)](https://github.com/peng-lab/napari-basicpy/raw/main/LICENSE) [![PyPI](https://img.shields.io/pypi/v/napari-basicpy.svg?color=green)](https://pypi.org/project/napari-basicpy) [![Python Version](https://img.shields.io/pypi/pyversions/napari-basicpy.svg?color=green)](https://python.org) -[![tests](https://github.com/tdmorello/napari-basicpy/workflows/tests/badge.svg)](https://github.com/tdmorello/napari-basicpy/actions) -[![codecov](https://codecov.io/gh/tdmorello/napari-basicpy/branch/main/graph/badge.svg)](https://codecov.io/gh/tdmorello/napari-basicpy) +[![tests](https://github.com/peng-lab/napari-basicpy/workflows/tests/badge.svg)](https://github.com/peng-lab/napari-basicpy/actions) +[![codecov](https://codecov.io/ghpeng-lab/napari-basicpy/branch/main/graph/badge.svg)](https://codecov.io/gh/peng-lab/napari-basicpy) [![napari hub](https://img.shields.io/endpoint?url=https://api.napari-hub.org/shields/napari-basicpy)](https://napari-hub.org/plugins/napari-basicpy) BaSiCPy illumination correction for napari @@ -27,17 +27,19 @@ https://napari.org/plugins/stable/index.html ## Installation -**Important note** M1/M2 mac and Windows users may need to install the `jax` and `jaxlib` following the instruction [here](https://github.com/peng-lab/BaSiCPy#installation). - You can install `napari-basicpy` via [pip]: pip install napari-basicpy +### Compatibility + +`napari-basicpy` (>=1.0.0) requires **BaSiCPy ≥ 2.0**. +If you need compatibility with **BaSiCPy < 2.0**, please use an earlier plugin version: To install latest development version : - pip install git+https://github.com/tdmorello/napari-basicpy.git + pip install git+https://github.com/peng-lab/napari-basicpy.git ## Contributing @@ -64,7 +66,7 @@ If you encounter any problems, please [file an issue] along with a detailed desc [Mozilla Public License 2.0]: https://www.mozilla.org/media/MPL/2.0/index.txt [cookiecutter-napari-plugin]: https://github.com/napari/cookiecutter-napari-plugin -[file an issue]: https://github.com/tdmorello/napari-basicpy/issues +[file an issue]: https://github.com/peng-lab/napari-basicpy/issues [napari]: https://github.com/napari/napari [tox]: https://tox.readthedocs.io/en/latest/ diff --git a/pyproject.toml b/pyproject.toml index 81215e5..dd02c9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -6,8 +6,102 @@ build-backend = "setuptools.build_meta" write_to = "src/napari_basicpy/_version.py" [tool.isort] -"line_length" = 88 -"profile" = "black" +line_length = 120 +profile = "black" [tool.black] -"line_length" = 88 +line_length = 120 + +[tool.ruff] +line-length = 120 + +[tool.pytest.ini_options] +log_cli = true +log_cli_level = "INFO" +log_cli_format = "%(asctime)s [%(levelname)8s] %(message)s (%(filename)s:%(lineno)s)" +log_cli_date_format = "%Y-%m-%d %H:%M:%S" + +[project] +name = "napari-basicpy" +dynamic = ["version"] +description = "BaSiCPy illumination correction for napari" +readme = "README.md" +requires-python = ">=3.9" +license = { text = "BSD-3-Clause" } +authors = [ + { name = "Yu Liu", email = "liuyu9671@gmail.com" }, + { name = "Tim Morello", email = "tdmorello@gmail.com" } +] +classifiers = [ + "Development Status :: 2 - Pre-Alpha", + "Framework :: napari", + "Intended Audience :: Developers", + "License :: OSI Approved :: BSD License", + "Operating System :: OS Independent", + "Programming Language :: Python", + "Programming Language :: Python :: 3", + "Programming Language :: Python :: 3 :: Only", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering :: Image Processing" +] +dependencies = [ + "numpy", + "qtpy", + "aicsimageio", + "basicpy>=2.0.0", + "matplotlib", +] + +[project.urls] +Homepage = "https://github.com/peng-lab/napari-basicpy" +Bug-Tracker = "https://github.com/peng-lab/napari-basicpy/issues" +Documentation = "https://github.com/peng-lab/napari-basicpy" +Source = "https://github.com/peng-lab/napari-basicpy" +Support = "https://github.com/peng-lab/napari-basicpy/issues" + +[project.entry-points."napari.manifest"] +napari_basicpy = "napari_basicpy:napari.yaml" + +[project.optional-dependencies] +dev = [ + "black", + "flake8", + "flake8-black", + "flake8-docstrings", + "flake8-isort", + "isort", + "mypy", + "pre-commit", + "pydocstyle", + "pytest", + "pytest-qt" +] +testing = [ + "tox", + "pytest", + "pytest-cov", + "pytest-qt", + "napari" +] +tox-testing = [ + "tox", + "pytest", + "pytest-cov", + "pytest-qt", + "napari", + "pyqt5" +] + +[tool.setuptools] +include-package-data = true + +[tool.setuptools.package-dir] +"" = "src" + +[tool.setuptools.packages.find] +where = ["src"] + +[tool.setuptools.package-data] +"*" = ["*.yaml"] \ No newline at end of file diff --git a/resources/logo.png b/resources/logo.png index 3565bba..4682be0 100644 Binary files a/resources/logo.png and b/resources/logo.png differ diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index bc223a2..0000000 --- a/setup.cfg +++ /dev/null @@ -1,71 +0,0 @@ -[metadata] -name = napari-basicpy -version = file: VERSION -description = BaSiCPy illumination correction for napari -long_description = file: README.md -long_description_content_type = text/markdown -url = https://github.com/tdmorello/napari-basicpy -author = Tim Morello -author_email = tdmorello@gmail.com -license = BSD-3-Clause -license_file = LICENSE -classifiers = - Development Status :: 2 - Pre-Alpha - Intended Audience :: Developers - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python - Programming Language :: Python :: 3 - Programming Language :: Python :: 3 :: Only - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - Topic :: Scientific/Engineering :: Image Processing -project_urls = - Bug Tracker = https://github.com/tdmorello/napari-basicpy/issues - Documentation = https://github.com/tdmorello/napari-basicpy#README.md - Source Code = https://github.com/tdmorello/napari-basicpy - User Support = https://github.com/tdmorello/napari-basicpy/issues - -[options] -packages = find: -install_requires = - basicpy - numpy - qtpy -python_requires = >=3.8 -include_package_data = True -package_dir = - = src - -[options.packages.find] -where = src - -[options.entry_points] -napari.manifest = - napari_basicpy = napari_basicpy:napari.yaml - -[options.extras_require] -dev = - black - flake8 - flake8-black - flake8-docstrings - flake8-isort - isort - mypy - pre-commit - pydocstyle - pytest - pytest-qt - -testing = - tox - pytest # https://docs.pytest.org/en/latest/contents.html - pytest-cov # https://pytest-cov.readthedocs.io/en/latest/ - pytest-qt # https://pytest-qt.readthedocs.io/en/latest/ - napari - pyqt5 - -[options.package_data] -* = *.yaml diff --git a/src/napari_basicpy/_icons/logo.png b/src/napari_basicpy/_icons/logo.png index 3565bba..4682be0 100644 Binary files a/src/napari_basicpy/_icons/logo.png and b/src/napari_basicpy/_icons/logo.png differ diff --git a/src/napari_basicpy/_tests/test_sample_data.py b/src/napari_basicpy/_tests/test_sample_data.py index 369c582..29fe77f 100644 --- a/src/napari_basicpy/_tests/test_sample_data.py +++ b/src/napari_basicpy/_tests/test_sample_data.py @@ -3,11 +3,6 @@ def test_data(make_napari_viewer): samples = [ "sample_data_random", - "sample_data_cell_culture", - "sample_data_timelapse_brightfield", - "sample_data_timelapse_nanog", - "sample_data_timelapse_pu1", - "sample_data_wsi_brain", ] viewer = make_napari_viewer() @@ -18,6 +13,3 @@ def test_data(make_napari_viewer): sample, ) assert len(viewer.layers) == (n + 1) - - # NOTE - # assert viewer.layers[0].dtype == np.uint8 diff --git a/src/napari_basicpy/_tests/test_widget.py b/src/napari_basicpy/_tests/test_widget.py index ffb8145..f9b3638 100644 --- a/src/napari_basicpy/_tests/test_widget.py +++ b/src/napari_basicpy/_tests/test_widget.py @@ -1,14 +1,7 @@ -"""Testing functions.""" +from napari_basicpy._widget import BasicWidget -from time import sleep - -from napari_basicpy import BasicWidget - - -# NOTE this test depends on `make_sample_data` working, might be bad design -# alternative, get pytest fixture from BaSiCPy package and use here -def test_q_widget(make_napari_viewer): +def test_q_widget(make_napari_viewer, qtbot): viewer = make_napari_viewer() widget = BasicWidget(viewer) @@ -17,12 +10,15 @@ def test_q_widget(make_napari_viewer): viewer.open_sample("napari-basicpy", "sample_data_random") assert len(viewer.layers) == 1 - worker = widget._run() - sleep(1) + widget.reset_choices() + widget.fit_image_select.value = viewer.layers[0] + + worker = widget._run_fit() + assert worker is not None + + with qtbot.waitSignal(worker.finished, timeout=60000): + pass - while True: - if worker.is_running: - continue - else: - # assert len(viewer.layers) >= 2 - break + layer_names = [layer.name for layer in viewer.layers] + assert "corrected" in layer_names + assert "flatfield" in layer_names \ No newline at end of file diff --git a/src/napari_basicpy/_version.py b/src/napari_basicpy/_version.py new file mode 100644 index 0000000..bc44466 --- /dev/null +++ b/src/napari_basicpy/_version.py @@ -0,0 +1,21 @@ +# file generated by setuptools-scm +# don't change, don't track in version control + +__all__ = ["__version__", "__version_tuple__", "version", "version_tuple"] + +TYPE_CHECKING = False +if TYPE_CHECKING: + from typing import Tuple + from typing import Union + + VERSION_TUPLE = Tuple[Union[int, str], ...] +else: + VERSION_TUPLE = object + +version: str +__version__: str +__version_tuple__: VERSION_TUPLE +version_tuple: VERSION_TUPLE + +__version__ = version = '0.0.4.dev20+g3002be9' +__version_tuple__ = version_tuple = (0, 0, 4, 'dev20', 'g3002be9') diff --git a/src/napari_basicpy/_widget.py b/src/napari_basicpy/_widget.py index baf985e..b2b9e39 100644 --- a/src/napari_basicpy/_widget.py +++ b/src/napari_basicpy/_widget.py @@ -1,10 +1,21 @@ +""" +TODO +[ ] Add Autosegment feature when checkbox is marked +[ ] Add text instructions to "Hover input field for tooltip" +""" + +SEQ_SENTINEL = "__SEQ_SENTINEL__" + +import tqdm +from napari.utils.notifications import show_info, show_warning import enum +import re import logging -import pkg_resources from functools import partial from pathlib import Path from typing import TYPE_CHECKING, Optional - +import importlib.metadata +import tifffile import numpy as np from basicpy import BaSiC from magicgui.widgets import create_widget @@ -12,6 +23,7 @@ from qtpy.QtCore import QEvent, Qt from qtpy.QtGui import QDoubleValidator, QPixmap from qtpy.QtWidgets import ( + QComboBox, QCheckBox, QDoubleSpinBox, QFormLayout, @@ -21,14 +33,329 @@ QScrollArea, QVBoxLayout, QWidget, + QGridLayout, + QSlider, + QSizePolicy, + QLineEdit, + QDialog, + QMessageBox, ) +from matplotlib.backends.backend_qt5agg import FigureCanvas +from .utils import _cast_with_scaling if TYPE_CHECKING: import napari # pragma: no cover +from magicgui.widgets import ComboBox +from napari.layers import Image +import numpy as np +import tifffile +from qtpy.QtWidgets import QFileDialog + +SHOW_LOGO = True # Show or hide the BaSiC logo in the widget + logger = logging.getLogger(__name__) -BASICPY_VERSION = pkg_resources.get_distribution("BaSiCPy").version +import tempfile +import os + +cache_path = tempfile.gettempdir() + + +def save_dialog(parent, file_name): + """ + Opens a dialog to select a location to save a file + + Parameters + ---------- + parent : QWidget + Parent widget for the dialog + + Returns + ------- + str + Path of selected file + """ + dialog = QFileDialog() + filepath, _ = dialog.getSaveFileName( + parent, + "Select location for {} to be saved".format(file_name), + "./{}.tif".format(file_name), + filter="TIFF files (*tif *.tiff)", + ) + if not (filepath.endswith(".tiff") or filepath.endswith(".tif")): + filepath += ".tiff" + return filepath + + +def write_tiff(path: str, data: np.ndarray): + """ + Write data to a TIFF file + + Parameters + ---------- + path : str + Path to save the file + data : np.ndarray + Data to save + """ + tifffile.imwrite(path, data) + + +class GeneralSetting(QGroupBox): + # (15.11.2024) Function 1 + def __init__(self, parent=None): + super().__init__(parent) + self.setVisible(False) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) + self.setStyleSheet("QGroupBox { " "border-radius: 10px}") + self.viewer = parent.viewer + self.parent = parent + self.name = "" # layer.name + + # layout and parameters for intensity normalization + vbox = QGridLayout() + self.setLayout(vbox) + + skip = [ + "resize_mode", + "resize_params", + "working_size", + "fitting_mode", + "fitting_mode", + "get_darkfield", + "smoothness_flatfield", + "smoothness_darkfield", + "sparse_cost_darkfield", + "sort_intensity", + "device", + ] + self._settings = {k: self.build_widget(k) for k in BaSiC().settings.keys() if k not in skip} + + # sort settings into either simple or advanced settings containers + # _settings = {**{"device": ComboBox(choices=["cpu", "cuda"])}, **_settings} + i = 0 + for k, v in self._settings.items(): + vbox.addWidget(QLabel(k), i, 0, 1, 1) + vbox.addWidget(v.native, i, 1, 1, 1) + i += 1 + + def build_widget(self, k): + field = BaSiC.model_fields[k] + description = field.description + default = field.default + annotation = field.annotation + # Handle enumerated settings + try: + if issubclass(annotation, enum.Enum): + try: + default = annotation[default] + except KeyError: + default = default + except TypeError: + pass + # Define when to use scientific notation spinbox based on default value + if (type(default) == float or type(default) == int) and (default < 0.01 or default > 999): + widget = ScientificDoubleSpinBox() + widget.native.setValue(default) + widget.native.adjustSize() + else: + widget = create_widget( + value=default, + annotation=annotation, + options={"tooltip": description}, + ) + return widget + + +class AutotuneSetting(QGroupBox): + # (15.11.2024) Function 1 + def __init__(self, parent=None): + super().__init__(parent) + self.setVisible(False) + self.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Maximum) + self.setStyleSheet("QGroupBox { " "border-radius: 10px}") + self.viewer = parent.viewer + self.parent = parent + self.name = "" # layer.name + + # layout and parameters for intensity normalization + vbox = QGridLayout() + self.setLayout(vbox) + + args = [ + "histogram_qmin", + "histogram_qmax", + "vmin_factor", + "vrange_factor", + "histogram_bins", + "histogram_use_fitting_weight", + "fourier_l0_norm_image_threshold", + "fourier_l0_norm_fourier_radius", + "fourier_l0_norm_threshold", + "fourier_l0_norm_cost_coef", + ] + + _default = { + "histogram_qmin": 0.01, + "histogram_qmax": 0.99, + "vmin_factor": 0.6, + "vrange_factor": 1.5, + "histogram_bins": 1000, + "histogram_use_fitting_weight": True, + "fourier_l0_norm_image_threshold": 0.1, + "fourier_l0_norm_fourier_radius": 10, + "fourier_l0_norm_threshold": 0.0, + "fourier_l0_norm_cost_coef": 30, + } + + self._settings = {k: self.build_widget(k, _default[k]) for k in args} + # sort settings into either simple or advanced settings containers + # _settings = {**{"device": ComboBox(choices=["cpu", "cuda"])}, **_settings} + i = 0 + for k, v in self._settings.items(): + vbox.addWidget(QLabel(k), i, 0, 1, 1) + vbox.addWidget(v.native, i, 1, 1, 1) + i += 1 + + def build_widget(self, k, default): + # Handle enumerated settings + annotation = type(default) + try: + if issubclass(annotation, enum.Enum): + try: + default = annotation[default] + except KeyError: + default = default + except TypeError: + pass + + # Define when to use scientific notation spinbox based on default value + if (type(default) == float or type(default) == int) and (default < 0.01 or default > 999): + widget = ScientificDoubleSpinBox() + widget.native.setValue(default) + widget.native.adjustSize() + else: + widget = create_widget( + value=default, + annotation=annotation, + ) + # widget.native.setMinimumWidth(150) + return widget + + +class SequenceDialog(QDialog): + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Choose image sequence folder") + self.setModal(True) + + self.folder_le = QLineEdit(self) + self.filter_le = QLineEdit(self) + self.out_folder_le = QLineEdit(self) + + browse_btn = QPushButton("Browse", self) # input folder + browse_out_btn = QPushButton("Browse", self) # output folder + ok_btn = QPushButton("OK", self) + cancel_btn = QPushButton("Cancel", self) + + layout = QGridLayout(self) + layout.addWidget(QLabel("Folder:"), 0, 0) + layout.addWidget(self.folder_le, 0, 1) + layout.addWidget(browse_btn, 0, 2) + + layout.addWidget(QLabel("Filter (comma-separated):"), 1, 0) + layout.addWidget(self.filter_le, 1, 1, 1, 2) + + layout.addWidget(QLabel("Output folder:"), 2, 0) + layout.addWidget(self.out_folder_le, 2, 1) + layout.addWidget(browse_out_btn, 2, 2) + + layout.addWidget(ok_btn, 3, 1) + layout.addWidget(cancel_btn, 3, 2) + + browse_btn.clicked.connect(self._browse) + browse_out_btn.clicked.connect(self._browse_out) + ok_btn.clicked.connect(self.accept) + cancel_btn.clicked.connect(self.reject) + + def _browse(self): + path = QFileDialog.getExistingDirectory(self, "Select Directory") + if path: + self.folder_le.setText(path) + + def _browse_out(self): + path = QFileDialog.getExistingDirectory(self, "Select Output Directory") + if path: + self.out_folder_le.setText(path) + + @property + def folder(self) -> str: + return self.folder_le.text().strip() + + @property + def filters(self) -> str: + return self.filter_le.text().strip() + + @property + def filters_tokens(self) -> list[str]: + return parse_filter_text(self.filter_le.text()) + + @property + def out_folder(self) -> str: + return self.out_folder_le.text().strip() + + +def parse_filter_text(s: str) -> list[str]: + if s is None: + return [] + s = s.replace(", ", ",") + tokens = [t.strip() for t in s.split(",") if t.strip()] + return tokens + + +class SaveOptionsDialog(QDialog): + + def __init__(self, parent=None): + super().__init__(parent) + self.setWindowTitle("Save options") + layout = QGridLayout(self) + + self.dtype_cb = QComboBox(self) + self.dtype_cb.addItems(["float32", "uint16", "uint8"]) + + self.mode_cb = QComboBox(self) + self.mode_cb.addItems( + [ + "preserve (no clip, auto-rescale if out-of-range)", + "rescale to full range", + ] + ) + + layout.addWidget(QLabel("Save dtype:"), 0, 0) + layout.addWidget(self.dtype_cb, 0, 1) + layout.addWidget(QLabel("Scaling:"), 1, 0) + layout.addWidget(self.mode_cb, 1, 1) + + btn_ok = QPushButton("OK", self) + btn_cancel = QPushButton("Cancel", self) + btn_ok.clicked.connect(self.accept) + btn_cancel.clicked.connect(self.reject) + layout.addWidget(btn_ok, 2, 0) + layout.addWidget(btn_cancel, 2, 1) + + if hasattr(parent, "_last_save_dtype"): + self.dtype_cb.setCurrentText(parent._last_save_dtype) + if hasattr(parent, "_last_save_mode"): + self.mode_cb.setCurrentText(parent._last_save_mode) + + @property + def dtype(self) -> str: + return self.dtype_cb.currentText() + + @property + def mode(self) -> str: + return self.mode_cb.currentText() class BasicWidget(QWidget): @@ -39,274 +366,1077 @@ def __init__(self, viewer: "napari.viewer.Viewer"): super().__init__() self.viewer = viewer + + # Define builder functions + widget = QWidget() + main_layout = QGridLayout() + widget.setLayout(main_layout) + + # Define builder functions + def build_header_container(): + """Build the widget header.""" + header_container = QWidget() + header_layout = QVBoxLayout() + header_container.setLayout(header_layout) + # show/hide logo + if SHOW_LOGO: + logo_path = str((Path(__file__).parent / "_icons/logo.png").absolute()) + logo_pm = QPixmap(logo_path) + logo_lbl = QLabel() + logo_lbl.setPixmap(logo_pm) + logo_lbl.setAlignment(Qt.AlignCenter) + header_layout.addWidget(logo_lbl) + # Show label and package version of BaSiCPy + lbl = QLabel(f"BaSiCPy Shading Correction") + lbl.setAlignment(Qt.AlignCenter) + header_layout.addWidget(lbl) + + return header_container + + def build_doc_reference_label(): + doc_reference_label = QLabel() + doc_reference_label.setOpenExternalLinks(True) + # doc_reference_label.setText( + # '' + # "See docs for settings details" + # ) + + doc_reference_label.setText( + 'See docs for settings details' + ) + + return doc_reference_label + + # Build fit widget components + header_container = build_header_container() + doc_reference_lbl = build_doc_reference_label() + self.fit_widget = self.build_fit_widget_container() + self.transform_widget = self.build_transform_widget_container() + + # Add containers/widgets to layout + + self.btn_fit = QPushButton("Fit BaSiCPy") + self.btn_fit.setCheckable(True) + self.btn_fit.clicked.connect(self.toggle_fit) + self.btn_fit.setStyleSheet("""QPushButton{background:green;border-radius:5px;}""") + self.btn_fit.setFixedWidth(400) + + self.btn_transform = QPushButton("Apply BaSiCPy") + self.btn_transform.setCheckable(True) + self.btn_transform.clicked.connect(self.toggle_transform) + self.btn_transform.setStyleSheet("""QPushButton{background:green;border-radius:5px;}""") + self.btn_transform.setFixedWidth(400) + + main_layout.addWidget(header_container, 0, 0, 1, 2) + main_layout.addWidget(self.btn_fit, 1, 0) + main_layout.addWidget(self.fit_widget, 2, 0) + main_layout.addWidget(self.btn_transform, 3, 0) + main_layout.addWidget(self.transform_widget, 4, 0) + main_layout.addWidget(doc_reference_lbl, 6, 0) + + main_layout.setAlignment(Qt.AlignTop) + + scroll_area = QScrollArea() + scroll_area.setWidget(widget) + scroll_area.setWidgetResizable(True) + self.setLayout(QVBoxLayout()) + self.layout().addWidget(scroll_area) - layer_select_layout = QFormLayout() - layer_select_layout.setFieldGrowthPolicy(QFormLayout.AllNonFixedFieldsGrow) - self.layer_select = create_widget( - annotation="napari.layers.Layer", label="layer_select" - ) - layer_select_layout.addRow("layer", self.layer_select.native) - layer_select_container = QWidget() - layer_select_container.setLayout(layer_select_layout) - - simple_settings, advanced_settings = self._build_settings_containers() - self.advanced_settings = advanced_settings - - self.run_btn = QPushButton("Run") - self.run_btn.clicked.connect(self._run) - self.cancel_btn = QPushButton("Cancel") - - # header - header = self.build_header() - self.layout().addWidget(header) - - self.layout().addWidget(layer_select_container) - self.layout().addWidget(simple_settings) - - # toggle advanced settings visibility - self.toggle_advanced_cb = QCheckBox("Show Advanced Settings") - tb_doc_reference = QLabel() - tb_doc_reference.setOpenExternalLinks(True) - tb_doc_reference.setText( - '' - "See docs for settings details" - ) - self.layout().addWidget(tb_doc_reference) + self.run_fit_btn.clicked.connect(self._run_fit) + self.autotune_btn.clicked.connect(self._run_autotune) + self.run_transform_btn.clicked.connect(self._run_transform) + self.save_fit_btn.clicked.connect(self._save_fit) + self.save_transform_btn.clicked.connect(self._save_transform) - self.layout().addWidget(self.toggle_advanced_cb) - self.toggle_advanced_cb.stateChanged.connect(self.toggle_advanced_settings) + def build_transform_widget_container(self): + settings_container = QGroupBox("Parameters") # make groupbox + settings_layout = QGridLayout() + label_timelapse = QLabel("is_timelapse:") + label_timelapse.setFixedWidth(150) + self.checkbox_is_timelapse_transform = QCheckBox() + self.checkbox_is_timelapse_transform.setChecked(False) - self.advanced_settings.setVisible(False) - self.layout().addWidget(advanced_settings) - self.layout().addWidget(self.run_btn) - self.layout().addWidget(self.cancel_btn) + settings_layout.addWidget(label_timelapse, 0, 0) + settings_layout.addWidget(self.checkbox_is_timelapse_transform, 0, 1) - def _build_settings_containers(self): - skip = [ - "resize_mode", - "resize_params", - "working_size", - ] + settings_layout.setAlignment(Qt.AlignTop) + settings_container.setLayout(settings_layout) - advanced = [ - "epsilon", - "estimation_mode", - "fitting_mode", - "lambda_darkfield_coef", - "lambda_darkfield_sparse_coef", - "lambda_darkfield", - "lambda_flatfield_coef", - "lambda_flatfield", - "max_iterations", - "max_mu_coef", - "max_reweight_iterations_baseline", - "max_reweight_iterations", - "mu_coef", - "optimization_tol_diff", - "optimization_tol", - "resize_mode", - "resize_params", - "reweighting_tol", - "rho", - "sort_intensity", - "varying_coeff", - "working_size", - # "get_darkfield", - ] + inputs_container = self.build_transform_inputs_containers() + + self.run_transform_btn = QPushButton("Run") + self.cancel_transform_btn = QPushButton("Cancel") + self.save_transform_btn = QPushButton("Save") + + transform_layout = QGridLayout() + transform_layout.addWidget(inputs_container, 0, 0, 1, 2) + transform_layout.addWidget(settings_container, 1, 0, 1, 2) + transform_layout.addWidget(self.run_transform_btn, 2, 0, 1, 1) + transform_layout.addWidget(self.cancel_transform_btn, 2, 1, 1, 1) + transform_layout.addWidget(self.save_transform_btn, 3, 0, 1, 2) + transform_layout.setAlignment(Qt.AlignTop) + + transform_widget = QWidget() + transform_widget.setLayout(transform_layout) + transform_widget.setVisible(False) + + return transform_widget + + def build_fit_widget_container(self): + + settings_container = self.build_settings_containers() + inputs_container = self.build_inputs_containers() + + advanced_parameters = QGroupBox("Advanced parameters") + advanced_parameters_layout = QGridLayout() + advanced_parameters.setLayout(advanced_parameters_layout) + + # general settings + self.general_settings = GeneralSetting(self) + self.btn_general_settings = QPushButton("General settings") + self.btn_general_settings.setCheckable(True) + self.btn_general_settings.clicked.connect(self.toggle_general_settings) + self.checkbox_get_darkfield.clicked.connect(self.toggle_lineedit_smoothness_darkfield) + advanced_parameters_layout.addWidget(self.btn_general_settings) + + advanced_parameters_layout.addWidget(self.general_settings) + + # autotune settings + self.autotune_settings = AutotuneSetting(self) + self.btn_autotune_settings = QPushButton("Autotune settings") + self.btn_autotune_settings.setCheckable(True) + self.btn_autotune_settings.clicked.connect(self.toggle_autotune_settings) + advanced_parameters_layout.addWidget(self.btn_autotune_settings) + advanced_parameters_layout.addWidget(self.autotune_settings) + + self.run_fit_btn = QPushButton("Run") + self.cancel_fit_btn = QPushButton("Cancel") + self.save_fit_btn = QPushButton("Save") + + fit_layout = QGridLayout() + fit_layout.addWidget(inputs_container, 0, 0, 1, 2) + fit_layout.addWidget(settings_container, 1, 0, 1, 2) + fit_layout.addWidget(advanced_parameters, 2, 0, 1, 2) + fit_layout.addWidget(self.run_fit_btn, 3, 0, 1, 1) + fit_layout.addWidget(self.cancel_fit_btn, 3, 1, 1, 1) + fit_layout.addWidget(self.save_fit_btn, 4, 0, 1, 2) + fit_layout.setAlignment(Qt.AlignTop) + fit_widget = QWidget() + fit_widget.setLayout(fit_layout) + fit_widget.setVisible(False) + return fit_widget + + def build_transform_inputs_containers(self): + input_gb = QGroupBox("Inputs") + gb_layout = QGridLayout() + + label_image = QLabel("images:") + label_image.setFixedWidth(150) + label_flatfield = QLabel("flatfield:") + label_flatfield.setFixedWidth(150) + label_darkfield = QLabel("darkfield:") + label_darkfield.setFixedWidth(150) + label_weight = QLabel("Segmentation mask:") + label_weight.setFixedWidth(150) + + self.transform_image_select = ComboBox(choices=self.layers_image_transform) + self.transform_image_select.changed.connect(self._on_transform_image_changed) - def build_widget(k): - field = BaSiC.__fields__[k] - default = field.default - description = field.field_info.description - type_ = field.type_ + self.fit_weight_select = ComboBox(choices=self.layers_weight_transform) + self.checkbox_is_timelapse_transform.clicked.connect(self.toggle_weight_in_transform) + self.flatfield_select = ComboBox(choices=self.layers_image_flatfield) + self.darkfield_select = ComboBox(choices=self.layers_weight_darkfield) + + self.inverse_cb_transform = QCheckBox("Inverse") + self.inverse_cb_transform.setChecked(False) + + note = QLabel("1 = background, 0 = foreground") + note.setWordWrap(True) + note.setStyleSheet("color: gray;") + + gb_layout.addWidget(label_image, 0, 0, 1, 1) + gb_layout.addWidget(self.transform_image_select.native, 0, 1, 1, 2) + gb_layout.addWidget(label_flatfield, 1, 0, 1, 1) + gb_layout.addWidget(self.flatfield_select.native, 1, 1, 1, 2) + gb_layout.addWidget(label_darkfield, 2, 0, 1, 1) + gb_layout.addWidget(self.darkfield_select.native, 2, 1, 1, 2) + gb_layout.addWidget(label_weight, 3, 0, 1, 1) + gb_layout.addWidget(self.fit_weight_select.native, 3, 1, 1, 1) + gb_layout.addWidget(self.inverse_cb_transform, 3, 2, 1, 1) + gb_layout.addWidget(note, 4, 1, 1, 2) + + gb_layout.setAlignment(Qt.AlignTop) + input_gb.setLayout(gb_layout) + + return input_gb + + def _fast_count_files(self, folder: str, tokens: list[str], hard_limit: int = 1_000_000): + """尽量快地统计匹配个数;用 scandir 并可提前停止。""" + cnt = 0 + try: + with os.scandir(folder) as it: + for e in it: + if not e.is_file(): + continue + name = e.name + ok = True + for t in tokens or []: + if t and t not in name: + ok = False + break + if ok: + cnt += 1 + if cnt >= hard_limit: # 防止极端目录把统计时间拖太久 + break + except Exception: + logger.exception("fast count failed") + return cnt + + def _on_transform_image_changed(self, value): + if value == SEQ_SENTINEL: + dlg = SequenceDialog(self) + if dlg.exec_() == QDialog.Accepted: + self.transform_sequence_folder = dlg.folder + self.transform_sequence_filters = dlg.filters_tokens + self.transform_sequence_out_folder = dlg.out_folder + + if not self.transform_sequence_folder: + QMessageBox.warning(self, "No folder", "Please choose a source folder.") + elif not self.transform_sequence_out_folder: + QMessageBox.warning(self, "No output folder", "Please choose an output folder.") + elif os.path.abspath(self.transform_sequence_out_folder) == os.path.abspath( + self.transform_sequence_folder + ): + QMessageBox.warning( + self, "Output = Input", "Output folder must be different from the source folder." + ) + else: + from napari.qt import thread_worker + from napari.utils.notifications import show_info, show_warning + + # 防重复:如果还在统计,就别再启动 + if getattr(self, "_count_worker_running", False): + show_warning("Counting is already in progress…") + else: + self._count_worker_running = True + show_info( + "Sequence selected.\n" + f"Source: {self.transform_sequence_folder}\n" + f"Output: {self.transform_sequence_out_folder}\n" + f"Filters: {', '.join(self.transform_sequence_filters) if self.transform_sequence_filters else '(none)'}\n" + "Counting matched files…" + ) + + @thread_worker(start_thread=True) # 关键:自动启动 + def _count_worker(): + return self._fast_count_files( + self.transform_sequence_folder, + self.transform_sequence_filters, + ) + + def _on_done(n): + self._count_worker_running = False + show_info( + f"Matched files: {n}\n" + f"Source: {self.transform_sequence_folder}\n" + f"Output: {self.transform_sequence_out_folder}" + ) + + def _on_err(e=None): + self._count_worker_running = False + show_warning("Counting failed; see logs.") + + w = _count_worker() + w.returned.connect(_on_done) + w.errored.connect(_on_err) + # 注意:不要再调用 w.start() 了! + + # 重置下拉 try: - if issubclass(type_, enum.Enum): - try: - default = type_[default] - except KeyError: - default = default - except TypeError: + self.transform_image_select.value = "--select input images--" + except Exception: pass - # name = field.name - - if (type(default) == float or type(default) == int) and ( - default < 0.01 or default > 999 - ): - widget = ScientificDoubleSpinBox() - widget.native.setValue(default) - widget.native.adjustSize() - else: - widget = create_widget( - value=default, - annotation=type_, - options={"tooltip": description}, - ) - widget.native.setMinimumWidth(150) - return widget + def _natural_key(self, s: str): + return [int(t) if t.isdigit() else t.lower() for t in re.split(r"(\d+)", s)] - # all settings here will be used to initialize BaSiC - self._settings = { - k: build_widget(k) - for k in BaSiC().settings.keys() - # exclude settings - if k not in skip - } + def _list_sequence_files(self, folder: str, tokens: list[str]) -> list[str]: + names = [f for f in os.listdir(folder) if os.path.isfile(os.path.join(folder, f))] + for t in tokens or []: + names = [n for n in names if t in n] + names.sort(key=self._natural_key) + return [os.path.join(folder, n) for n in names] - self._extrasettings = dict() - # settings to display correction profiles - # options to show flatfield/darkfield profiles - # self._extrasettings["show_flatfield"] = create_widget( - # value=True, - # options={"tooltip": "Output flatfield profile with corrected image"}, - # ) - # self._extrasettings["show_darkfield"] = create_widget( - # value=True, - # options={"tooltip": "Output darkfield profile with corrected image"}, - # ) - self._extrasettings["get_timelapse"] = create_widget( - value=False, - options={"tooltip": "Output timelapse correction with corrected image"}, - ) + def _iter_chunks(self, seq: list, size: int): + for i in range(0, len(seq), size): + yield seq[i : i + size] - simple_settings_container = QGroupBox("Settings") - simple_settings_container.setLayout(QFormLayout()) - simple_settings_container.layout().setFieldGrowthPolicy( - QFormLayout.AllNonFixedFieldsGrow - ) + def build_inputs_containers(self): + input_gb = QGroupBox("Inputs") + gb_layout = QGridLayout() - # this mess is to put scrollArea INSIDE groupBox - advanced_settings_list = QWidget() - advanced_settings_list.setLayout(QFormLayout()) - advanced_settings_list.layout().setFieldGrowthPolicy( - QFormLayout.AllNonFixedFieldsGrow - ) + label_image = QLabel("images:") + label_image.setFixedWidth(150) + label_fitting_weight = QLabel("segmentation mask:") + label_fitting_weight.setFixedWidth(150) - for k, v in self._settings.items(): - if k in advanced: - # advanced_settings_container.layout().addRow(k, v.native) - advanced_settings_list.layout().addRow(k, v.native) - else: - simple_settings_container.layout().addRow(k, v.native) + note = QLabel("1 = background, 0 = foreground") + note.setWordWrap(True) + note.setStyleSheet("color: gray;") + + self.inverse_cb = QCheckBox("Inverse") + self.inverse_cb.setChecked(False) + + self.fit_image_select = ComboBox(choices=self.layers_image_fit) + self.weight_select = ComboBox(choices=self.layers_weight) + + gb_layout.addWidget(label_image, 0, 0, 1, 1) + gb_layout.addWidget(self.fit_image_select.native, 0, 1, 1, 2) + gb_layout.addWidget(label_fitting_weight, 1, 0, 1, 1) + gb_layout.addWidget(self.weight_select.native, 1, 1, 1, 1) # 之前是 (1,1,1,2) + gb_layout.addWidget(self.inverse_cb, 1, 2, 1, 1) + gb_layout.addWidget(note, 2, 1, 1, 2) + + gb_layout.setAlignment(Qt.AlignTop) + input_gb.setLayout(gb_layout) + + return input_gb + + def build_settings_containers(self): + simple_settings_gb = QGroupBox("Parameters") # make groupbox + gb_layout = QGridLayout() + + label_get_darkfield = QLabel("get_darkfield:") + label_timelapse = QLabel("is_timelapse:") + label_sorting = QLabel("sort_intensity:") + label_smoothness_flatfield = QLabel("smoothness_flatfield:") + label_smoothness_darkfield = QLabel("smoothness_darkfield:") + + label_get_darkfield.setFixedWidth(150) + label_timelapse.setFixedWidth(150) + label_sorting.setFixedWidth(150) + label_smoothness_flatfield.setFixedWidth(150) + label_smoothness_darkfield.setFixedWidth(150) + + self.lineedit_smoothness_flatfield = QLineEdit() + self.lineedit_smoothness_darkfield = QLineEdit() + self.lineedit_smoothness_darkfield.setEnabled(False) + self.lineedit_smoothness_darkfield.setText("Not available") + self.lineedit_smoothness_flatfield.setText("") + + self.autotune_btn = QPushButton("autotune") + self.autotune_btn.setSizePolicy(QSizePolicy.Preferred, QSizePolicy.Expanding) + + self.checkbox_get_darkfield = QCheckBox() + self.checkbox_get_darkfield.setChecked(False) + self.checkbox_is_timelapse = QCheckBox() + self.checkbox_is_timelapse.setChecked(False) + self.checkbox_sorting = QCheckBox() + self.checkbox_sorting.setChecked(False) + + gb_layout.addWidget(label_get_darkfield, 0, 0) + gb_layout.addWidget(self.checkbox_get_darkfield, 0, 1) + gb_layout.addWidget(label_timelapse, 1, 0) + gb_layout.addWidget(self.checkbox_is_timelapse, 1, 1) + gb_layout.addWidget(label_sorting, 2, 0) + gb_layout.addWidget(self.checkbox_sorting, 2, 1) + gb_layout.addWidget(label_smoothness_flatfield, 3, 0, 1, 1) + gb_layout.addWidget(self.lineedit_smoothness_flatfield, 3, 1, 1, 1) + gb_layout.addWidget(label_smoothness_darkfield, 4, 0, 1, 1) + gb_layout.addWidget(self.lineedit_smoothness_darkfield, 4, 1, 1, 1) + gb_layout.addWidget(self.autotune_btn, 3, 2, 2, 1) + + gb_layout.setAlignment(Qt.AlignTop) + simple_settings_gb.setLayout(gb_layout) + + return simple_settings_gb + + def toggle_lineedit_smoothness_darkfield(self, checked: bool): + if self.checkbox_get_darkfield.isChecked(): + self.lineedit_smoothness_darkfield.setEnabled(True) + self.lineedit_smoothness_darkfield.clear() + else: + self.lineedit_smoothness_darkfield.setEnabled(False) + self.lineedit_smoothness_darkfield.setText("Not available") + + def toggle_transform(self, checked: bool): + # Switching the visibility of the transform_widget + if self.transform_widget.isVisible(): + self.transform_widget.setVisible(False) + else: + self.transform_widget.setVisible(True) + self.fit_widget.setVisible(False) + + def toggle_fit(self, checked: bool): + # Switching the visibility of the fit_widget + if self.fit_widget.isVisible(): + self.fit_widget.setVisible(False) + else: + self.fit_widget.setVisible(True) + self.transform_widget.setVisible(False) + + def toggle_weight_in_transform(self, checked: bool): + # Switching the visibility of the fit_widget + if self.checkbox_is_timelapse_transform.isChecked(): + self.fit_weight_select.enabled = True + else: + self.fit_weight_select.enabled = False + + def toggle_general_settings(self, checked: bool): + # Switching the visibility of the General settings + if self.general_settings.isVisible(): + self.general_settings.setVisible(False) + self.btn_general_settings.setText("General settings") + else: + self.general_settings.setVisible(True) + self.btn_general_settings.setText("Hide general settings") + + def toggle_autotune_settings(self, checked: bool): + # Switching the visibility of the Autotune settings + if self.autotune_settings.isVisible(): + self.autotune_settings.setVisible(False) + self.btn_autotune_settings.setText("Autotune settings") + else: + self.autotune_settings.setVisible(True) + self.btn_autotune_settings.setText("Hide autotune settings") + + def layers_image_fit( + self, + wdg: ComboBox, + ) -> list[Image]: + return ["--select input images--"] + [layer for layer in self.viewer.layers] + + def layers_image_transform(self, wdg) -> list: + special = [ + ("--select input images--", "--select input images--"), + ("Choose sequence from a folder…", SEQ_SENTINEL), + ] + layer_items = [(layer.name, layer) for layer in self.viewer.layers] + return special + layer_items - advanced_settings_scroll = QScrollArea() - advanced_settings_scroll.setWidget(advanced_settings_list) + def layers_weight_transform( + self, + wdg: ComboBox, + ) -> list[Image]: + return ["none"] + [layer for layer in self.viewer.layers] - advanced_settings_container = QGroupBox("Advanced Settings") - advanced_settings_container.setLayout(QVBoxLayout()) - advanced_settings_container.layout().addWidget(advanced_settings_scroll) + def layers_weight_darkfield( + self, + wdg: ComboBox, + ) -> list[Image]: + return ["none"] + [layer for layer in self.viewer.layers] - for k, v in self._extrasettings.items(): - simple_settings_container.layout().addRow(k, v.native) + def layers_image_flatfield( + self, + wdg: ComboBox, + ) -> list[Image]: + return ["--select input images--"] + [layer for layer in self.viewer.layers] - return simple_settings_container, advanced_settings_container + def layers_weight( + self, + wdg: ComboBox, + ) -> list[Image]: + return ["none"] + [layer for layer in self.viewer.layers] @property def settings(self): """Get settings for BaSiC.""" return {k: v.value for k, v in self._settings.items()} - def _run(self): - - # TODO visualization (on button?) to represent that program is running + def _run_autotune(self): # disable run button - self.run_btn.setDisabled(True) + self.autotune_btn.setDisabled(True) + # get layer information + data, meta, _ = self.fit_image_select.value.as_layer_data_tuple() - data, meta, _ = self.layer_select.value.as_layer_data_tuple() + if self.weight_select.value == "none": + fitting_weight = None + else: + fitting_weight, meta_fitting_weight, _ = self.weight_select.value.as_layer_data_tuple() + if self.inverse_cb.isChecked(): + fitting_weight = fitting_weight > 0 + fitting_weight = 1 - fitting_weight + # define function to update napari viewer def update_layer(update): - # data, flatfield, darkfield, baseline, meta = update - data, flatfield, darkfield, meta = update - print(f"corrected shape: {data.shape}") - self.viewer.add_image(data, **meta) - self.viewer.add_image(flatfield) - if self._settings["get_darkfield"].value: - self.viewer.add_image(darkfield) - # if self._extrasettings["get_timelapse"].value: - # self.viewer.add_image(baseline) + smoothness_flatfield, smoothness_darkfield = update + self.lineedit_smoothness_flatfield.setText(str(smoothness_flatfield)) + if _settings["get_darkfield"]: + self.lineedit_smoothness_darkfield.setText(str(smoothness_darkfield)) @thread_worker( start_thread=False, # connect={"yielded": update_layer, "returned": update_layer}, connect={"returned": update_layer}, ) - def call_basic(data): - # TODO log basic output to a QtTextEdit or in a new window - basic = BaSiC(**self.settings) - logger.info( - "Calling `basic.fit_transform` with `get_timelapse=" - f"{self._extrasettings['get_timelapse'].value}`" - ) - corrected = basic.fit_transform( - data, timelapse=self._extrasettings["get_timelapse"].value + def call_autotune(data, fitting_weight, _settings, _settings_autotune): + basic = BaSiC(**_settings) + basic.autotune( + data, + is_timelapse=self.checkbox_is_timelapse.isChecked(), + fitting_weight=fitting_weight, + **_settings_autotune, ) + smoothness_flatfield = basic.smoothness_flatfield + smoothness_darkfield = basic.smoothness_darkfield + return smoothness_flatfield, smoothness_darkfield + + _settings_tmp = self.general_settings._settings + _settings = {} + for key, item in _settings_tmp.items(): + _settings[key] = item.value + + _settings.update( + { + "get_darkfield": self.checkbox_get_darkfield.isChecked(), + "sort_intensity": self.checkbox_sorting.isChecked(), + } + ) + + _settings_autotune_tmp = self.autotune_settings._settings + _settings_autotune = {} + for key, item in _settings_autotune_tmp.items(): + if key != "histogram_bins": + _settings_autotune[key] = item.value + else: + _settings_autotune[key] = int(item.value) + + worker = call_autotune(data, fitting_weight, _settings, _settings_autotune) + worker.finished.connect(lambda: self.autotune_btn.setDisabled(False)) + worker.errored.connect(lambda: self.autotune_btn.setDisabled(False)) + worker.start() + logger.info("Autotune worker started") + return worker + + def _estimate_batch_size(self, first_file, target_gb=0.5, hard_cap=64): + arr = tifffile.imread(first_file) + bytes_per = arr.nbytes if hasattr(arr, "nbytes") else np.asarray(arr).nbytes + if bytes_per <= 0: + return 16 + bs = max(1, int((target_gb * (1024**3)) // bytes_per)) + return min(bs, hard_cap) + + def _run_transform(self): + self.run_transform_btn.setDisabled(True) + + # ====== SEQUENCE 模式 ====== + if getattr(self, "transform_sequence_folder", None): + try: + src_dir = self.transform_sequence_folder + out_dir = getattr(self, "transform_sequence_out_folder", "") + if not out_dir: + QMessageBox.warning(self, "No output folder", "Please choose an output folder.") + self.run_transform_btn.setDisabled(False) + return + os.makedirs(out_dir, exist_ok=True) + + # 只构造文件名列表(不排序就不会阻塞太久;若一定要自然排序再排序) + names = [f for f in os.scandir(src_dir) if f.is_file()] + # 过滤 + tokens = getattr(self, "transform_sequence_filters", []) or [] + if tokens: + keep = [] + for e in names: + name = e.name + ok = True + for t in tokens: + if t and t not in name: + ok = False + break + if ok: + keep.append(e) + names = keep + if not names: + QMessageBox.warning(self, "No files", "No files matched your filters.") + self.run_transform_btn.setDisabled(False) + return + + # 可选:自然排序(慢一些,放在需要时) + names = sorted((e.name for e in names), key=self._natural_key) + files = [os.path.join(src_dir, n) for n in names] + + flatfield, _, _ = self.flatfield_select.value.as_layer_data_tuple() + if self.darkfield_select.value == "none": + darkfield = np.zeros_like(flatfield) + else: + darkfield, _, _ = self.darkfield_select.value.as_layer_data_tuple() + + # 序列模式禁用 mask + if self.fit_weight_select.value != "none": + QMessageBox.warning( + self, + "Segmentation mask ignored", + "Sequence mode does not support a per-frame segmentation mask. It will be ignored.", + ) + fitting_weight = None + + # 估算 batch 大小 + batch_size = self._estimate_batch_size(files[0], target_gb=0.5, hard_cap=64) + + def on_progress(state): + done, total = state + # 更新状态栏而不是弹无数提示 + self.viewer.status = f"BaSiCPy: {done}/{total} ({done/total:.1%})" + + def on_done(_out_dir): + QMessageBox.information(self, "Done", f"Saved corrected frames to:\n{_out_dir}") + try: + first_out = os.path.join(_out_dir, os.path.basename(files[0])) + preview = tifffile.imread(first_out) + self.viewer.add_image(preview, name="corrected_preview") + except Exception: + pass + self.run_transform_btn.setDisabled(False) + + """ + @thread_worker(start_thread=False, connect={"yielded": on_progress, "returned": on_done}) + def call_basic_sequence(files, out_dir, batch_size): + basic = BaSiC() + basic.darkfield = np.asarray(darkfield) + basic.flatfield = np.asarray(flatfield) + + total = len(files) + done = 0 + + for i in range(0, total, batch_size): + batch = files[i : i + batch_size] + # 逐个读,避免临时峰值内存过大;不需要 np.stack + corrected_list = [] + for fp in batch: + img = tifffile.imread(fp) + corr = basic.transform( + img, + is_timelapse=self.checkbox_is_timelapse_transform.isChecked(), + fitting_weight=fitting_weight, + ) + corrected_list.append(np.asarray(corr)) + + # 写回磁盘(文件名不变) + for fp, arr in zip(batch, corrected_list): + out_fp = os.path.join(out_dir, os.path.basename(fp)) + tifffile.imwrite(out_fp, arr) + + done += len(batch) + yield (done, total) + + return out_dir + """ + + @thread_worker(start_thread=False, connect={"yielded": on_progress, "returned": on_done}) + def call_basic_sequence(files, out_dir, _settings): + basic = BaSiC(**_settings) + basic.darkfield = np.asarray(darkfield) + basic.flatfield = np.asarray(flatfield) + + total = len(files) + done = 0 + batch_size = 50 # 固定一次 50 张 + + im_max = tifffile.imread(files[0]) + target_dtype = im_max.dtype + + if np.issubdtype(target_dtype, np.floating): + pass + else: + print("estimate dynamic range...") + for i in tqdm.tqdm(range(1, total, 20), leave=False): + im_max = np.maximum(im_max, tifffile.imread(files[i])) + im_max = im_max / basic.flatfield + + if target_dtype == np.uint8: + if im_max.max() > 255: + basic.flatfield = basic.flatfield / 255 * im_max.max() + elif target_dtype == np.uint16: + if im_max.max() > 65535: + basic.flatfield = basic.flatfield / 65535 * im_max.max() + else: + raise ValueError(f"Unsupported numpy dtype: {target_dtype}") + for i in tqdm.tqdm(range(0, total, batch_size), desc="transforming: "): + batch = files[i : i + batch_size] + + # 一次性读取 50 张并堆叠 + imgs = [tifffile.imread(fp) for fp in batch] + try: + stack = np.stack(imgs, axis=0) # 形状 ~ (B, Y, X) 或 (B, Z, Y, X) + except Exception: + # 如果形状不一致,明确报错,避免 silent fail + shapes = {np.asarray(im).shape for im in imgs} + raise ValueError(f"Images in this batch have different shapes: {shapes}") + + # 一次性做 transform + corrected = basic.transform( + stack, + is_timelapse=self.checkbox_is_timelapse_transform.isChecked(), # 批量按时间序列处理 + fitting_weight=None, # 序列模式禁用 mask + ) + corr = np.asarray(corrected) + + # 逐张写回(文件名保持不变) + if corr.ndim == 2: + # 极端情况:只有 1 张 + out_fp = os.path.join(out_dir, os.path.basename(batch[0])) + tifffile.imwrite(out_fp, corr) + else: + for j, src_fp in enumerate(batch): + out_fp = os.path.join(out_dir, os.path.basename(src_fp)) + tifffile.imwrite(out_fp, corr[j]) + + # 释放本批内存(可选) + del imgs, stack, corr + + done += len(batch) + yield (done, total) + + return out_dir + + _basic_settings_tmp = self.general_settings._settings + _basic_settings = {} + for key, item in _basic_settings_tmp.items(): + _basic_settings[key] = item.value + + _basic_settings.update( + { + "get_darkfield": self.checkbox_get_darkfield.isChecked(), + "sort_intensity": self.checkbox_sorting.isChecked(), + } + ) + + worker = call_basic_sequence(files, out_dir, _basic_settings) + worker.errored.connect(lambda e=None: self.run_transform_btn.setDisabled(False)) + self.cancel_transform_btn.clicked.connect(partial(self._cancel_transform, worker=worker)) + worker.finished.connect(self.cancel_transform_btn.clicked.disconnect) + worker.start() + return + + except Exception as e: + logger.exception("Sequence transform failed") + QMessageBox.critical(self, "Error", str(e)) + self.run_transform_btn.setDisabled(False) + return + + # ====== 否则:保持你原来的 layer → layer 流程(不变) ====== + try: + data, meta, _ = self.transform_image_select.value.as_layer_data_tuple() + flatfield, _, _ = self.flatfield_select.value.as_layer_data_tuple() + if self.darkfield_select.value == "none": + darkfield = np.zeros_like(flatfield) + else: + darkfield, _, _ = self.darkfield_select.value.as_layer_data_tuple() + if self.fit_weight_select.value == "none": + fitting_weight = None + else: + fitting_weight, _, _ = self.fit_weight_select.value.as_layer_data_tuple() + if self.inverse_cb_transform.isChecked(): + fitting_weight = fitting_weight > 0 + fitting_weight = 1 - fitting_weight + except: + logger.error("Error inputs.") + self.run_transform_btn.setDisabled(False) + return + + def update_layer(update): + data, meta = update + self.corrected = data + self.viewer.add_image(data, name="corrected") + print("Transform is done.") + + @thread_worker(start_thread=False, connect={"returned": update_layer}) + def call_basic(data, _settings, _basic_settings): + basic = BaSiC(**_basic_settings) + basic.darkfield = np.asarray(darkfield) + basic.flatfield = np.asarray(flatfield) + corrected = basic.transform(data, **_settings) + self.run_transform_btn.setDisabled(False) + return corrected, meta + + _settings = { + "is_timelapse": self.checkbox_is_timelapse_transform.isChecked(), + "fitting_weight": fitting_weight, + } + + _basic_settings_tmp = self.general_settings._settings + _basic_settings = {} + for key, item in _basic_settings_tmp.items(): + _basic_settings[key] = item.value + + _basic_settings.update( + { + "get_darkfield": self.checkbox_get_darkfield.isChecked(), + "sort_intensity": self.checkbox_sorting.isChecked(), + } + ) + + worker = call_basic(data, _settings, _basic_settings) + self.cancel_transform_btn.clicked.connect(partial(self._cancel_transform, worker=worker)) + worker.finished.connect(self.cancel_transform_btn.clicked.disconnect) + worker.finished.connect(lambda: self.run_transform_btn.setDisabled(False)) + worker.errored.connect(lambda: self.run_transform_btn.setDisabled(False)) + worker.start() + logger.info("BaSiC worker for tranform only started") + return worker + + def _run_fit(self): + # disable run button + self.run_fit_btn.setDisabled(True) + # get layer information + try: + data, meta, _ = self.fit_image_select.value.as_layer_data_tuple() + + if self.weight_select.value == "none": + fitting_weight = None + else: + fitting_weight, meta_fitting_weight, _ = self.weight_select.value.as_layer_data_tuple() + if self.inverse_cb.isChecked(): + fitting_weight = fitting_weight > 0 + fitting_weight = 1 - fitting_weight + except: + logger.error("Error inputs.") + self.run_fit_btn.setDisabled(False) + return + + # define function to update napari viewer + def update_layer(update): + uncorrected, data, flatfield, darkfield, _settings, meta = update + self.viewer.add_image(data, name="corrected") + self.viewer.add_image(flatfield, name="flatfield") + self.corrected = data + self.flatfield = flatfield + if _settings["get_darkfield"]: + self.viewer.add_image(darkfield, name="darkfield") + self.darkfield = darkfield + if self.checkbox_is_timelapse.isChecked(): + import matplotlib.pyplot as plt + import matplotlib.image as mpimg + + m, n = data.shape[-2:] + + fig, (ax1, ax2) = plt.subplots(1, 2) + # fig.tight_layout() + # fig.set_size_inches(n / 300, m / 300) + baseline_before = np.squeeze(np.asarray(uncorrected.mean((-2, -1)))) + baseline_after = np.squeeze(np.asarray(data.mean((-2, -1)))) + baseline_max = 1.01 * max(baseline_after.max(), baseline_before.max()) + baseline_min = 0.99 * min(baseline_after.min(), baseline_before.min()) + ax1.plot(baseline_before) + ax2.plot(baseline_after) + ax1.tick_params(labelsize=10) + ax2.tick_params(labelsize=10) + ax1.set_title("before BaSiCPy") + ax2.set_title("after BaSiCPy") + ax1.set_xlabel("slices") + ax2.set_xlabel("slices") + ax1.set_ylabel("baseline value") + # ax2.set_ylabel("baseline value") + ax1.set_ylim([baseline_min, baseline_max]) + ax2.set_ylim([baseline_min, baseline_max]) + + plt.savefig(os.path.join(cache_path, "baseline.jpg"), dpi=300) + baseline_image = mpimg.imread(os.path.join(cache_path, "baseline.jpg")) + self.viewer.add_image(baseline_image, name="baseline") + os.remove(os.path.join(cache_path, "baseline.jpg")) + print("BaSiCPy fit is done.") + + @thread_worker( + start_thread=False, + # connect={"yielded": update_layer, "returned": update_layer}, + connect={"returned": update_layer}, + ) + def call_basic(data, fitting_weight, _settings): + basic = BaSiC(**_settings) + corrected = basic( + data, + is_timelapse=self.checkbox_is_timelapse.isChecked(), + fitting_weight=fitting_weight, + ) flatfield = basic.flatfield darkfield = basic.darkfield + self.run_fit_btn.setDisabled(False) # reenable run button + return data, corrected, flatfield, darkfield, _settings, meta - if self._extrasettings["get_timelapse"]: - # flatfield = flatfield / basic.baseline - ... + _settings_tmp = self.general_settings._settings + _settings = {} + for key, item in _settings_tmp.items(): + _settings[key] = item.value - # reenable run button - # TODO also reenable when error occurs - self.run_btn.setDisabled(False) - - return corrected, flatfield, darkfield, meta + if self.lineedit_smoothness_flatfield.text() != "": + try: + params_smoothness_flatfield = float(self.lineedit_smoothness_flatfield.text()) + _settings.update({"smoothness_flatfield": params_smoothness_flatfield}) + except: + logger.warning("Invalid smoothness_flatfield") + if self.lineedit_smoothness_darkfield.isEnabled(): + try: + params_smoothness_darkfield = float(self.lineedit_smoothness_darkfield.text()) + _settings.update({"smoothness_darkfield": params_smoothness_darkfield}) + except: + logger.warning("Invalid smoothness_darkfield") - # TODO trigger error when BaSiC fails, re-enable "run" button - worker = call_basic(data) - self.cancel_btn.clicked.connect(partial(self._cancel, worker=worker)) - worker.finished.connect(self.cancel_btn.clicked.disconnect) - worker.errored.connect(lambda: self.run_btn.setDisabled(False)) + _settings.update( + { + "get_darkfield": self.checkbox_get_darkfield.isChecked(), + "sort_intensity": self.checkbox_sorting.isChecked(), + } + ) + worker = call_basic(data, fitting_weight, _settings) + self.cancel_fit_btn.clicked.connect(partial(self._cancel_fit, worker=worker)) + worker.finished.connect(self.cancel_fit_btn.clicked.disconnect) + worker.finished.connect(lambda: self.run_fit_btn.setDisabled(False)) + worker.errored.connect(lambda: self.run_fit_btn.setDisabled(False)) worker.start() + logger.info("BaSiC worker started") return worker - def _cancel(self, worker): + def _cancel_fit(self, worker): + logger.info("Cancel requested") + worker.quit() + # enable run button + worker.finished.connect(lambda: self.run_fit_btn.setDisabled(False)) + + def _cancel_transform(self, worker): logger.info("Cancel requested") worker.quit() # enable run button - worker.finished.connect(lambda: self.run_btn.setDisabled(False)) + worker.finished.connect(lambda: self.run_transform_btn.setDisabled(False)) + + # def _save_fit(self): + # try: + # filepath = save_dialog(self, "corrected_image") + # write_tiff(filepath, self.corrected) + # except: + # self.logger.info("Corrected image is not found.") + # try: + # filepath = save_dialog(self, "flatfield") + # data = self.flatfield.astype(np.float32) + # write_tiff(filepath, data) + # except: + # self.logger.info("Flatfield is not found.") + # if self.checkbox_get_darkfield.isChecked(): + # try: + # filepath = save_dialog(self, "darkfield") + # data = self.darkfield.astype(np.float32) + # write_tiff(filepath, data) + # except: + # self.logger.info("Darkfield is not found.") + # else: + # pass + # print("Saving is done.") + # return + + def _save_fit(self): + ok_any = False + try: + if hasattr(self, "corrected"): + opt = SaveOptionsDialog(parent=self) + if opt.exec_() == QDialog.Accepted: + self._last_save_dtype = opt.dtype + self._last_save_mode = opt.mode + + fp = save_dialog(self, "corrected_image") + if fp: + arr = _cast_with_scaling(self.corrected, opt.dtype, opt.mode) + write_tiff(fp, arr) + ok_any = True + else: + logger.info("No 'corrected' result to save in _save_fit().") + except Exception as e: + logger.exception("Failed to save corrected image (fit)") + QMessageBox.critical(self, "Save failed", f"Corrected image: {e}") + + try: + if hasattr(self, "flatfield"): + fp = save_dialog(self, "flatfield") + if fp: + write_tiff(fp, self.flatfield.astype(np.float32)) + ok_any = True + else: + logger.info("No 'flatfield' to save in _save_fit().") + except Exception as e: + logger.exception("Failed to save flatfield") + QMessageBox.critical(self, "Save failed", f"Flatfield: {e}") + + if self.checkbox_get_darkfield.isChecked(): + try: + if hasattr(self, "darkfield"): + fp = save_dialog(self, "darkfield") + if fp: + write_tiff(fp, self.darkfield.astype(np.float32)) + ok_any = True + else: + logger.info("No 'darkfield' to save in _save_fit().") + except Exception as e: + logger.exception("Failed to save darkfield") + QMessageBox.critical(self, "Save failed", f"Darkfield: {e}") + + if ok_any: + QMessageBox.information(self, "Saved", "Export finished successfully.") + try: + self.viewer.status = "BaSiCPy: export finished." + except Exception: + pass + else: + QMessageBox.information(self, "Nothing saved", "No file was selected or available.") + + # def _save_transform(self): + # try: + # filepath = save_dialog(self, "corrected_image") + # write_tiff(filepath, self.corrected) + # except: + # self.logger.info("Corrected image is not found.") + # print("Saving is done.") + # return + + def _save_transform(self): + try: + if hasattr(self, "corrected"): + opt = SaveOptionsDialog(parent=self) + if opt.exec_() != QDialog.Accepted: + return + self._last_save_dtype = opt.dtype + self._last_save_mode = opt.mode + + fp = save_dialog(self, "corrected_image") + if fp: + arr = _cast_with_scaling(self.corrected, opt.dtype, opt.mode) + write_tiff(fp, arr) + QMessageBox.information(self, "Saved", f"Saved to:\n{fp}") + else: + QMessageBox.warning(self, "No data", "Corrected image is not found.") + except Exception as e: + logger.exception("Failed to save corrected image") + QMessageBox.critical(self, "Save failed", str(e)) def showEvent(self, event: QEvent) -> None: # noqa: D102 super().showEvent(event) self.reset_choices() def reset_choices(self, event: Optional[QEvent] = None) -> None: - """Repopulate image list.""" # noqa DAR101 - self.layer_select.reset_choices(event) - if len(self.layer_select) < 1: - self.run_btn.setEnabled(False) - else: - self.run_btn.setEnabled(True) - - def toggle_advanced_settings(self) -> None: - """Toggle the advanced settings container.""" - # container = self.advanced_settings - container = self.advanced_settings - if self.toggle_advanced_cb.isChecked(): - container.setHidden(False) - else: - container.setHidden(True) - - def build_header(self): - """Build a header.""" + """Repopulate image layer dropdown list.""" # noqa DAR101 + self.fit_image_select.reset_choices(event) + self.transform_image_select.reset_choices(event) + self.flatfield_select.reset_choices(event) + self.darkfield_select.reset_choices(event) - logo_path = Path(__file__).parent / "_icons/logo.png" - logo_pm = QPixmap(str(logo_path.absolute())) - logo_lbl = QLabel() - logo_lbl.setPixmap(logo_pm) - logo_lbl.setAlignment(Qt.AlignCenter) - lbl = QLabel(f"BaSiC Shading Correction v{BASICPY_VERSION}") - lbl.setAlignment(Qt.AlignCenter) + self.weight_select.reset_choices(event) + self.fit_weight_select.reset_choices(event) - header = QWidget() - header.setLayout(QVBoxLayout()) - header.layout().addWidget(logo_lbl) - header.layout().addWidget(lbl) + # # If no layers are present, disable the 'run' button + # print(self.fit_image_select.value) + # print(self.fit_image_select.value is "--select input images--") + # if self.fit_image_select.value is "--select input images--": + # self.run_fit_btn.setEnabled(False) + # self.autotune_btn.setEnabled(False) + # else: + # self.run_fit_btn.setEnabled(True) + # self.autotune_btn.setEnabled(True) - return header + # if (self.transform_image_select.value is "--select input images--") and ( + # self.flatfield_select.value is "--select input images--" + # ): + # self.run_transform_btn.setEnabled(False) + # else: + # self.run_transform_btn.setEnabled(True) class QScientificDoubleSpinBox(QDoubleSpinBox): diff --git a/src/napari_basicpy/_writer.py b/src/napari_basicpy/_writer.py new file mode 100644 index 0000000..83fe8cd --- /dev/null +++ b/src/napari_basicpy/_writer.py @@ -0,0 +1,54 @@ +""" +This module is an example of a barebones writer plugin for napari. + +It implements the Writer specification. +see: https://napari.org/stable/plugins/guides.html?#writers + +Replace code below according to your needs. +""" + +from __future__ import annotations + +import numpy as np +import tifffile +from qtpy.QtWidgets import QFileDialog + + +def save_dialog(parent): + """ + Opens a dialog to select a location to save a file + + Parameters + ---------- + parent : QWidget + Parent widget for the dialog + + Returns + ------- + str + Path of selected file + """ + dialog = QFileDialog() + filepath, _ = dialog.getSaveFileName( + parent, + "Select location for TIFF-File to be created", + filter="TIFF files (*tif *.tiff)", + ) + if not (filepath.endswith(".tiff") or filepath.endswith(".tif")): + filepath += ".tiff" + return filepath + + +def write_tiff(path: str, data: np.ndarray): + """ + Write data to a TIFF file + + Parameters + ---------- + path : str + Path to save the file + data : np.ndarray + Data to save + """ + data = data.astype(np.uint16) + OmeTiffWriter.save(data, path, dim_order_out="YX") diff --git a/src/napari_basicpy/napari.yaml b/src/napari_basicpy/napari.yaml index 02e91e7..e1c4838 100644 --- a/src/napari_basicpy/napari.yaml +++ b/src/napari_basicpy/napari.yaml @@ -1,33 +1,40 @@ name: napari-basicpy -display_name: BaSiCpy shadow correction in napari +display_name: BaSiCPy Shadow Correction schema_version: 0.1.0 + contributions: commands: - id: napari-basicpy.shadow_correction - title: Apply BaSiC Shadow Correction + title: Apply BaSiCPy Shadow Correction python_name: napari_basicpy._widget:BasicWidget + - id: napari-basicpy.sample_data_random - title: Provide artifical sample data + title: Provide artificial sample data python_name: napari_basicpy._sample_data:make_sample_data_random + - id: napari-basicpy.sample_data_cell_culture - title: Provide artifical example + title: Provide artificial example python_name: napari_basicpy._sample_data:make_sample_data_cell_culture + - id: napari-basicpy.sample_data_timelapse_brightfield - title: Provide artifical example + title: Provide artificial example python_name: napari_basicpy._sample_data:make_sample_data_timelapse_brightfield + - id: napari-basicpy.sample_data_timelapse_nanog - title: Provide artifical example + title: Provide artificial example python_name: napari_basicpy._sample_data:make_sample_data_timelapse_nanog + - id: napari-basicpy.sample_data_timelapse_pu1 - title: Provide artifical example + title: Provide artificial example python_name: napari_basicpy._sample_data:make_sample_data_timelapse_pu1 + - id: napari-basicpy.sample_data_wsi_brain - title: Provide artifical example + title: Provide artificial example python_name: napari_basicpy._sample_data:make_sample_data_wsi_brain widgets: - command: napari-basicpy.shadow_correction - display_name: BaSiC Shadow Correction + display_name: BaSiCPy Shadow Correction sample_data: - key: sample_data_random @@ -47,4 +54,4 @@ contributions: command: napari-basicpy.sample_data_timelapse_pu1 - key: sample_data_wsi_brain display_name: WSI Brain - command: napari-basicpy.sample_data_wsi_brain + command: napari-basicpy.sample_data_wsi_brain \ No newline at end of file diff --git a/src/napari_basicpy/test.py b/src/napari_basicpy/test.py new file mode 100644 index 0000000..115343e --- /dev/null +++ b/src/napari_basicpy/test.py @@ -0,0 +1,28 @@ +import matplotlib.pyplot as plt +import numpy as np + +m = 2048 +n = 2048 + +# plt.figure(figsize=(7, 3), tight_layout=True) +fig, (ax1, ax2) = plt.subplots(1, 2) +# fig.tight_layout() +# fig.set_size_inches(n / 300, m / 300) +baseline_before = np.random.rand(100) * 200 +baseline_after = np.random.rand(100) * 200 +baseline_max = 1.01 * max(baseline_after.max(), baseline_before.max()) +baseline_min = 0.99 * min(baseline_after.min(), baseline_before.min()) +ax1.plot(baseline_before) +ax2.plot(baseline_after) +ax1.tick_params(labelsize=10) +ax2.tick_params(labelsize=10) +ax1.set_title("before BaSiCPy") +ax2.set_title("after BaSiCPy") +ax1.set_xlabel("slices") +ax2.set_xlabel("slices") +ax1.set_ylabel("baseline value") +# ax2.set_ylabel("baseline value") +ax1.set_ylim([baseline_min, baseline_max]) +ax2.set_ylim([baseline_min, baseline_max]) + +plt.show() diff --git a/src/napari_basicpy/utils.py b/src/napari_basicpy/utils.py new file mode 100644 index 0000000..0bcbfd5 --- /dev/null +++ b/src/napari_basicpy/utils.py @@ -0,0 +1,53 @@ +import torch +import tqdm +import numpy as np + + +def _dtype_limits(dtype): + dt = np.dtype(dtype) + if np.issubdtype(dt, np.integer): + info = np.iinfo(dt) + return info.min, info.max + return None, None + + +def _cast_with_scaling( + arr: np.ndarray, + target_dtype: str, + mode: str, +): + a = np.asarray(arr) + + if target_dtype == "float32": + return a.astype(np.float32, copy=False) + + tmin, tmax = _dtype_limits(target_dtype) + if tmin is None: + return a.astype(np.float32, copy=False) + + if mode == "preserve (no clip, auto-rescale if out-of-range)": + a_min = float(np.nanmin(a)) + a_max = float(np.nanmax(a)) + + if a_min >= tmin and a_max <= tmax: + return a.astype(target_dtype, copy=False) + + if not np.isfinite(a_min) or not np.isfinite(a_max) or a_max <= a_min: + scaled = np.zeros_like(a, dtype=np.float32) + else: + scaled = (a - a_min) / (a_max - a_min) + out = (scaled * (tmax - tmin) + tmin).round() + return np.clip(out, tmin, tmax).astype(target_dtype, copy=False) + + if mode == "rescale to full range": + a_min = float(np.nanmin(a)) + a_max = float(np.nanmax(a)) + if not np.isfinite(a_min) or not np.isfinite(a_max) or a_max <= a_min: + scaled = np.zeros_like(a, dtype=np.float32) + else: + scaled = (a - a_min) / (a_max - a_min) + out = (scaled * (tmax - tmin) + tmin).round() + return np.clip(out, tmin, tmax).astype(target_dtype, copy=False) + + # fallback + return a.astype(target_dtype, copy=False) diff --git a/tox.ini b/tox.ini index 45378fc..9dedc2c 100644 --- a/tox.ini +++ b/tox.ini @@ -1,13 +1,13 @@ # For more information about tox, see https://tox.readthedocs.io/en/latest/ [tox] -envlist = py{38,39,310}-{linux,macos,windows} +envlist = py{38,39,310,311}-{linux,macos,windows} isolated_build=true [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311 [gh-actions:env] PLATFORM = @@ -23,12 +23,11 @@ platform = passenv = CI GITHUB_ACTIONS - DISPLAY + DISPLAY XAUTHORITY NUMPY_EXPERIMENTAL_ARRAY_FUNCTION PYVISTA_OFF_SCREEN extras = - testing -commands = - pip install 'git+https://github.com/peng-lab/BaSiCPy.git@dev' - pytest -v --color=yes --cov=napari_basicpy --cov-report=xml + tox-testing +commands = + pytest -v --cov=napari_basicpy --cov-report=xml