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
-[](https://github.com/tdmorello/napari-basicpy/raw/main/LICENSE)
+[](https://github.com/peng-lab/napari-basicpy/raw/main/LICENSE)
[](https://pypi.org/project/napari-basicpy)
[](https://python.org)
-[](https://github.com/tdmorello/napari-basicpy/actions)
-[](https://codecov.io/gh/tdmorello/napari-basicpy)
+[](https://github.com/peng-lab/napari-basicpy/actions)
+[](https://codecov.io/gh/peng-lab/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