From 4a30e88b8066c0d9e9194991fac95ff89c2840e1 Mon Sep 17 00:00:00 2001 From: Yu Liu Date: Mon, 9 Mar 2026 15:24:59 +0100 Subject: [PATCH 1/8] push yu's version --- .github/workflows/test_and_deploy.yml | 59 +- .gitignore | 48 +- .pre-commit-config.yaml | 47 +- MANIFEST.in | 4 +- pyproject.toml | 100 +- resources/logo.png | Bin 22358 -> 12567 bytes setup.cfg | 71 -- src/napari_basicpy/_icons/logo.png | Bin 22358 -> 12567 bytes src/napari_basicpy/_version.py | 21 + src/napari_basicpy/_widget.py | 1558 +++++++++++++++++++++---- src/napari_basicpy/_writer.py | 54 + src/napari_basicpy/napari.yaml | 27 +- src/napari_basicpy/test.py | 28 + src/napari_basicpy/utils.py | 53 + tox.ini | 12 +- 15 files changed, 1687 insertions(+), 395 deletions(-) delete mode 100644 setup.cfg create mode 100644 src/napari_basicpy/_version.py create mode 100644 src/napari_basicpy/_writer.py create mode 100644 src/napari_basicpy/test.py create mode 100644 src/napari_basicpy/utils.py diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index ca01fc5..7f7b303 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.8", "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/pyproject.toml b/pyproject.toml index 81215e5..541fd2b 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.8" +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.8", + "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" +] + +[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 3565bbaeab1faccd556c1f712dd25dde21b3f947..4682be09a1a6400623ffdcb5c3b47ff08219ad35 100644 GIT binary patch literal 12567 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 literal 22358 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 N002ovPDHLkV1l#XP=5db 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 3565bbaeab1faccd556c1f712dd25dde21b3f947..4682be09a1a6400623ffdcb5c3b47ff08219ad35 100644 GIT binary patch literal 12567 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 literal 22358 zcmV)IK)k<+P)82!xRc76=eRD5ur#&g|^u?&(xj_x{g4=iKU=w5*UscpjB#cBZGg zySnbVC%or9=P0}$kN>a$6o3kV5`Yvy6F?yE9LqUn04)F=Ky50Z@bc|IZ0HmVi=rxF zK=r{QLBlShpi0mxMAyV9sv-(1!GxW|CcOcB%>A&o4^RdE?9_eeJO+cU2uy%!JAq0t zh>EFTHl0CVFn~k_SWFk8bclY#uj?X3)s`@47m;XpAqKGSVzoRhLS2Ej7F|}W zfpiuKHUhQ{m`Ola09E39n9Kkwk{cQVX2CrPpa|Fi2>O9EIR~~=MHnWq%6*TCK~?^X zB7!py*ye7)YS=`>m@d!aF+pxF}Hn|Npy%WlQ>ZiF{~{3mA@t@Qq%NJ7RUvCzugPsrgE_e+VRS!Rr_HZIP`wJKa}dd%*8=9%fW7Od zaW|e4!k?SNnb&kZfK#AKA4IV3SfvUqZ?L_Gb0@Oj%_?6`iTf0=D--mML02zCI&~wE zTnrTM`AOfAr-bn5;&8&%`BenNzXsL!ETsm-@iuD++My2H`8AN9P53i)H~~nGhU&Ws!R8H&0me_D z**)AN0rb!&=z*JH(+yAdr#+-{?n7G zub$F`KNE*>sg)a4u<1fxlEW21f6f!_BPxlUOMo8!Q&^*xz(7y%<0&EhsUEZl?*R1O zK=l&X6v`%0)@BcvE)0w%e0k}^LLr4#3A9R~p|S@b?j*DHMUUxFq74VP0P5}T+Bl{8 z#6O-A!k^0Fa=6`%fPNqJ*gJvJkv+&|sa7M%+3BGb%1$kNAOUo8qDaH7bu5=7WrJ1EWTmGh!_4n@ z*m`iu=le8YjwZT?AVesaz?5OAe)2 zDEz-q$YDi0t|f>bi_}Y>bHoS)AAm78Kt`u_jpPOpNxYR4Zi|id`lD# z9j_7{sno)#gov|ba+P1}!_#uBE&bsHy!J}rFT_@NO?J)q zU1p}2{HeSNw*4Hy{`+IRIfp!c=7o^TF^~oclC%hQDWKjJA#88g$6f3a4Q>Hqck`Vr zViOdC@dny20)ib1>bex>k7J|-1Cs*56S{+YhZC;&m|{@-N;L>a&`r^`h9EU#0%(Ht z46O1?uO-+x)F$x)WP84$HLLq$wkK>9bXX;?R!bSx+3CwSC}WNqdP&5+^gOrUiA?bl z7Q!daFZ`JnLJM2!AYNZqrS}F(Uo)&m7e@4G&HXJj!x>f^YX%>*)i5^RI24#)-i&pw z29;oCtaRU>M@Bt6R2r#`Idh?u$cEy@ zqzs2p?^RA~t$7t{$v-5xzO=;h_ss_`b~Su2`zasbc*B>R=fcKMQ>uDYMU`LOuZGaIDPrr= zXe=yF>p5A|PW}Q#3uvq?uW{I$g3SbRHPgHDIg5awRUW$W(amA(CnL}`y}?oe68DnK zwAgnM@ccDcsbQ&{12z46sOZlh>%Cj+_?Z$ykMMVqoVQL5e`!>WL`79V$Hs_l7j7+$ z8?ILAvatRL;n-+wXD_l;qdEa}9l@L23=9 zwnBG~w&Xjyl?t8rV=NMrpsmwR%d`(RZ6}DGpk#iKhH@!h_ic34S5k%HvJFtQ0bN5! zJB@6S+7zLRkhp7ADFPd^<8K2bDui;^;9g;si;>Y*qy!}+qL4UkZY65amd+*3NYcFA zcU1gb9-A@zcV&9M8_S$pMMJ7=v2@OqFS)8lCTLepV#Zk##Af4I;RlM`Zl9y%2 zPfru7C9VqHnnb0~8d3wFt)UvWo#umQh4fC;bRz=swOG!T_*f1&P=Q-msl+s?m5j1R}^2YqYM(#J_?ZzP%;`F(z^vj zP6J!kSQaq}pEJ=~-amCwvQj9JskZ0@8ZqI9mcEBFip(zQWv|&ptBNfa?UXNA(k#GY z)|EJ%{PNs+BD~7K@2W?zWTW>E-CG7Kw5pf5r1bFzC_gr4y#(& zHZko~?NzK*nd&EVX57a6=75bo2SmoiOX%xrw_U zmZivvZ{mK`3M%CQ$9jLzJ9C2l*(@Q~`~Rh+2R@%)?_cSD&p}6-_Ek#-H52*{5YzxO z^LC^Q&*hZf<2!yjLRbdu{au*2AcN&Q(N@BDeUyrnudv8O!EL04wXo(&}zf{&Bv~0$B zgUGgULSguKrQA8}vpKC+aOPY&lWj9U(Yp+x1-)Mt3SA`vETZHBt*+tk87hUgQs_XT zB4*odhL~EIa4<#!Z}?s$Fk(!-$7j*=m-1Z0EKXiwdNV|5Nx+nI3y9P(bTQ1-u~6~7 zk5?Q1bc9f}cyjk53J6 z&#NQNmB}+Lw)?x$mS02fp-6}W%ZQ)zuiWb*?X;OC@uW}H{CNRsKp#-JO`_44vbGI{ zKpNBYmNF(bV2wsWm%Aoo5jnvLc!LyEoqZQgop>xFr9_x4LKC3q+yU|B0vn(OgL$2x zs6v#CdoD#&8Fs%#Q_}ZPUh`!Rfhf8j7~{>jAlx}k8pvi%a|@^0=@0t)VXJ=z@UZyW6lppz!_#RDZ~)cbVe*O4@lsq|*dV_bYASu^>?=e&R? z6&uS@stb0kW=xSIXn7qix)Y+5d^Qpb5&A&!=d+2mqKOmS1?Ea=PM9bmgm4%uo2ErA@%NBoMoe67Xi?s5p?#l1fyr1;jQv z09(rqHkqmAt+zD6$EWJda`fPu09J3f66xfnP|=N#M;v~#LTFKj!3kvh+~$ffq5Fal ztS@bNp<_0sNa@C^9Aw}ZNXwJf|4cnsz~&pXWNP|7QIh;} zxSg@=$0>hvydY|DxL`@BoDh1CAP`M!#P4JO)M;@`(KERRiX(0QS(wsb&F!N0B!hNj zbBVyampG9sIe~RSP{P-$1?%glD>Ag{LKkGI0DZ;~4Cw@#PyvFx#5&0Px*uEGnSCqR zVJ-)4Jy0eApZKQi5~V#*oSrMghLw|G?azS_TI@oKi=eRqMf|=B0@yT#4gwr8d>jfW zqiq&R07b{F+3wabxG61A(r!vfBd^MkAWil8xpI zO%!WOLf{l@z91FhcY%r1K48Ij+EhzDNG#XvHV0j+F$lE}Hi8Ti(V~qs1t|)N)7X;n zA>oFW@6?NQEuEw2J92G_pCj^(tHS2oM=8W->oYi47rUjqbk`>49qj;Kwz~Po5B3@puhBfIhuj>EIa4_lz(! zmtU=h{TME_ojFV9k(^y`Llx4vTtA>WJYY z(=%H9Kr5iSCc$>s@69-TaHXtQ)EOj2`p~-}mUm~A(f2KBaHaXT5**&iRv{2a&k38` z2yO!g0*6^bHi1QzqN|O7=01s&<@VUW7Jt$@@LG>v0<-s6Ah?%B>anHepLikUYkey! z;k%vLWzRWb|JP}jlpSl=SRe=k6pIBUP7_)dK$l*lRJjtE#IkRqGG9-zmLgNBB@hs; zD8JJ#&jmG9%B=)rr1Ll%5s8NIAU2zNZ)#Zhjq9Lh#0+`7)~eo;glC7^&N5Qm?m zXG7+j{8I`@G`RsJ&{~28*?1Hid!JXcP+Icq3~A!fvPOewd@{#wGYiJ3#ghw-Y+vOy z8OS^b0fCR1J5vz6(15X0ax*BKWDpsJMHQgsTo%m)3@rhXCuLC7P1lmb1_S-D!`H&p zcXO)jv8D5#3{CiE6^8F|t7p4XtLn6YVN=G*U5C%4NV^89F^H^pgn5rM7NRTEGwDvx|5kB505Nc=i0lqT(i4v7w(77cP=K}*E# zH6)Rta;7S9;u-L@(D$G~R8u4y5aY)M9D+4+ijduqB}`OJ+R6PMiUd)hMdqGyNWT$T zSd8S)wdAZt>a36^+_{EZ%8_szCy<(gqu4t7tz`@o0ZPzlYJ)bJYPJycy|lOPM} zqvfkirQv4ni$LRKsOXNzCWMba_2*aS?J897b^xhy=_O|tQd#gcg-uoNR0A}C+O}a- zhpLD>DeqdXvV?CS0!VNLIs1Y5Et=s%a1g)>&vQ^D8Wl}wGB7p}?U0IJ>Ag#N#x0y> z-zqREYfFJ)jA$PEo66;62@5prXOhtJ`|8YQ^D5CxM0Hx#qd1e`X!ga7CrqFR=mu)LuuPc2A2`K(%eS%Qxsez77V2Q&-3b|ekM?u(WV z5VTpfAwQ9b5^h6_U()jzTdvWj^S7Q4+rESGh{HQVB!_+O+J|!`DtI*oU9lxCX31$` z&&j49%3~ohaSF@=n@`_{s8m98ejZVvNvHr$XK^H`$rsWR43MVKS~4H3kBnl-ArONI zp&+H+JZs-h0~u&cRx0J2rvk%1pWt`Kfv_2?ilgd0%}EO}BBe+^l!X$rs@$~)q-i`I zOG&Cv;AP%|iv2b%f}oV@Wi32XqE)8Zjmu+Od9~jZLf~*JR%O>~7!S1`CuNI}D&DXz z05W3g-M^{B7wLW$5TyzI-ll+ru}Js&$ZFRcjIc(BsnV@VGvb_RW*-2$g&uh|F!Lv{ zo%Z2EHcyl}{tyakgPTsE6QRo)n*}4ZoC3_7f(GtOFuZ*Pn=jsm#m1sARE&kqcJeu0Lzcj&6MJVF z-Q$1|ZYdj(-5x$S%>`+Z_U9q zsvN=WZy+ob1R`N*C4>_5EAWh@$O6mGw$Gp_$uE=WNW|;OMc&+cE{@x!Rx$^qpS3_6 zp7|n2V7w(&*)9)+eBe`SJ{XX-7m<<9`O5o_0fWzq;S$&$>DCGo;3Hdjz&z7va##+a zj4)-D2$?^hydA^BX#*{6Brl@!Udr%i8G(XCf2IDqg>XyvgOv;+tac9 z^^n%KSgpCjR>X^OZRqtlL&#z~gtajrnoFfWtx)`orC z0#$BVdXH2#Cegrrri!FKfweA^GV&^zy?+PTi5~@GKN05mG_hpd#x9YB6LGBgM%u*` zco-PiF@)28_ZjFvb{Ng6c_E|*w3MMo{>gL&5wfxRKYFv7=?DAo!TlL{@LGdY?}IswW;c<{6-<%5=# zeX{aU1(m)EozrSJ(R^qDaWkRlI}AgV*Of6mJVe*gj$2rqZje9iuv3$yhzbSN)(v8D zp^mwGXQ3j6{_O)8*))WO@p&YPVW`B{KT!}QgtTsoQYk`ltc)-$A+-tS_s*g*(;`jj zqGz{JRUtrOYahy+sx;ZEHQz-0V3QTPa{*lDh4a~m)N zMk}8cVFryhB8;h{Q)yHNtC)Up4v*gOC>jqn5S0Qb9c107tdAxWgUW_J3>`a!k)ua2 zuw?-2hsUtkZlb=hh(HHD4j|DbG*R+Ncx{0b^y1q=Hj>F=*MRweU{e_QTTbj5-s$f1 zRTyA+-3Z-;`)<4!4_tR27Ve+M^j*`W#oTueq7Z{e4`KMY5p28U2pqF}7dC9zg879R zbmrSA^_8JZ5pMtMTk-vmduo85-89OW0T9~ z*JjZ%q;7;*CZ?zLdY!W*hwO~J(@fwn7~jugNx9aY-`XqDdq!{w(ds=9s}|B^eZxi&7OC7C|RnKH5m`ROn`u1p@n^VGv^d;3nMm$y;&V zOaFzkL5F+HKQN0gzUI@IxOFdff9Sa=M}3%gmZGqVFXcYVSqK$UwChZz=_csw@5kWK z2)=v$t@z?=KZC}eg~zmhjjB($^?zc=8}{R<_neJVEkb*~3ms}%DvqQTr7A6& zvH|iuM5R0q3xR9~fuMUOlJPGxD!Wo6_Uecp$HTLLocSbec)!Kaks~3d8!bonvy?F0DhU-%m|9-PDGv5g3ANL6zM z6VlJl;f@>#Oc{d!1%#xP1Jc+b|84^ciXw!FOhABeKwfG{=dD|}0efzJ2-m;j-yV|y z=8yS@<`5+X^r1*8#{`&FNK%mTRlc=+1e z@x#}C1%+A}VYP%X)nWnoM54GU$qcQiPtN;yQYxy3UkliG!gjALYndZY0PF5kz z!+01N-n9-V|LKM3j#$(u<|u9F9Bxmw@)sUZZa83vUre)mj)<)~B)^a}q<*}PE6^BU z#8*Ce6Dla9TqqF>dhWjym$3$-jR-Y`;Zo zUma0U5|it-rUy2A><^Vinp${+ffNt%GkT5a>6~9b6DL3S6wHk;Ac_J)#_##c1Nh!2 zzlXHFvhBYoT0O#Gnawj@G+Vd@fo38nXm*DgZ z&cuPK{bq>~ZyjM+po{KRr~1U267aY89f+*pfffo>g3KJR6~Uiy@dB2iJWUC;yMral-j0;o$5+q%lxXZ1P7pY(Qgl0nLRb`F^8YMlmuxg4vmAf{F?Q zMcSfY{^QSMaboQNhHV&xBTvn zxc?*HX8{Zp&iKnqu;sF&F|~KPM+>uzANuT2M+1dap)Fk|JUZ@)wo4DoT1-_gfH~;K zmhD+8gloAk0{5sdOpER5So6Tq>rIgLQ*`ot_E9ZB6qj}*?N zO7Z?IGCwe@iW33=6+8KiNmdoH@@UL9Fo^@0ou1-`XVzfdkj1lK`z(Cf!A@0LSGRZhlh#X{m^X>V(;w}n7HQv=J!lPhYCq-(KlMb#uL}$$n%cE zktZLG#cmS|vkPplB2Cc6{IbvT$JSG}Ve9Z#JUY7%jb@!*)3hywr4YwF{TS>z_ar1H z#>~M<(#Edjp;GHZd#;IVuYMoy`@+)6uc!RV>G-9;elhwBMHH)LJoN4Rar@tYXH5Zo z=G!m9bKZRo<}r);gLCM{2~xuqceY?r8Lr~ezj+>tBSjp%bsRh2_B3p~pdH8p1cf@J-xw?N{)? z%?~13T$|Am6gAHNjdO6#d#^!xbPO|7GYCT7BvIe$xAN{U+=bh}{T-Zn<~f)hn!`e? zj%H&KX{(DycLB{}i?bDOF@gX|nxI@PV&>o!9{&1Di0A$r?nZZ}iNP(y^qTuVdl$h& zs~yKacPF0v?iXUdHILcJDfa;+ee+W!|8i=xHRF4g!3FTq15rO(LO15U_ zLA!d>FCU9V+k|OYgkg_)xBSR>b7E$DO?-5&)dUy!^g5!ft_k}G2Cxwukk&X$Q$ima zohCl|rjMbyig`Na*(YPPG>koS`%vw#l4kt-H~u}obnQ)t_4U(^!8iW*H&B11j#qu^ zRTvztVR2@WV(Im}Hm>>_XC^=R3;!=Je#0d=_eJMp-S+huDG#A-T3DE`!?a@xa6E;L zykeI>FNP@f7ZEp>;)La)K189@z7blT2JZXf>ciSy@bm1qUWqQ+SeTwcF)AWf+*!i8 zLJt3Q(2EWt#Pc1P*tH(9=u9>!O&5k*a?Osy<^r@7UCBvFdA|staJq9%Iteda)x@TN zt^vv1b78x;`yk`72_cg##t`5pRlpGh$4mlSE;t&MQwK3WwZJ<~+HK?mCgrg>Eu9Yp zhFRLp#8VEu5mvoU467X9yY72P+FjJA8kB{Yy!Rk}@VPrN_0aT+lXqQxBA)Z+D={0- zBWcGN9UQ~G-?{Iw1u%d7;JP2+D}VK0xct?ZqZu@jAi)Xe?j&Z=%p)s5yE(mx&%Et= z-1ui-z>&{55@$X4X*m9p6R>{AMyh?S&(ERTilGB$TQhFDx)Y*=xZy0o_}&G~?VVrs z8jpYWPHa7MD`uvrIU^;Ne@3DQPxGSp3lwZSrT#j?7|E&}`2|_*K^Q4BfDE+lS>B8k z>{pve#><04_x@!-RbLM~_W?jtM09vU$UdO!Sg{>N{FBo9I7*W>IFcN^XcJ-w>vo+F z!TkyX>q(s|yPYcMH}p_Ixz+idq)@%!peA@x#DX533|PEue9*j^=b@)z2C`ejN)&EIdsqMM#-vCgr8lbiqf5-mc1OaXy+eNC{C@7Ry= z+s9GSWm<&k))X#&$wj#2O_vOCAD-!o(cn43~2$U@Blo4{KZ|kFa zIoRA6Uw%!#m?tah^u7DC?odDe;cm{?KKu>b^0!~dxi2{fzxk)HM0vP^so5#Y-b~F* zWBu0k_`RFnggy5>f@5|bi(^KQLjz4LqE04f*Up`Y{#OxiyX9?&>$#xC@iOk-hZ8S8 z3FRO{XQ6>bXuRzGFUOH*9Enf9`jcqPtgfZ4#?Uy1#n^lEH+6!N9KV zhFIwu5e936_}o6GmlkDiDznJFzoxJZPm!7+@89{N0~vb8f-5}39*v7eYe+&2Da_^5+=(qm; zH}L5*ci_s`J&(44TCs?^wIQ$~M*o%?3Z(+N?UmYa_Tf27Jt`?r?Tw94(ge!(y8maw zlnXeF&GdjZ-dg)AJI*8b$`JUyz4^|}dcH1VNk?|~hGmUl^yz?JLkL-${yqlr9_O@j zM^e84qR11Vu)c(J*b0d4Kg8pKy*X_@{X%8#6mc!tDxBfB6;MbMt+eePngKZ?z+BB~;sz^}q=fnvEtp-41H&YS{f-yK%{@FU2FbJ&YgT zd>3xJ{x&@PwFk3Jvi$hk$8N^y*PM+F>o!sU>(zFkH>)wa7}z|B+Ll2ayniK7K7Qvu zCaXEvryEoKv=uFC#1HIM%PG>F2^B+0HJ`8O%azhf;doXrtrZtaLHWRBmHydQukMCX zZukEbz2Hq-fk^{~E2C=8E^*f9HNo-1R87uiGxII9B;k#rM={!lA80sBNjO zDunlb{!UDMdmpx(xfS~!o#5u}ic@da7yZ)~N|3}Uf}sNXYZWZcHppLfdcv=!$qL_9 zuWgF&Gy$8jiL$y^IHL}8>*)vwep(TO6ODG845kDDfd3aY|fdA<%ct=bC@N(ZGNtf z*_jzUH1QDjV*;oB;_0Z4RaY+bJ^L}ccLt*v!@vFcb!!V?0Q9e~VA~m6vF+?_C=QfX zoT7YpFU1|jx@i~1z9NRl1_``&;BRZOgYg3sc=X^Sm~YMC@+)`al^_0%RT=LtD2cl< zSJkZr`dXeJ165w$hNm311Q?sBW$j z`+WRM4^wWtdc+`1no{t|oj`iI|GbW&lG40JI$tTmA{vsDgD{Hz6nm zS-AqW$moIbv-td_pTsv_`e}5go9H`okk*zk-K+x3S0hSvEPb{ih4D-7 z&5~d9$|WyH0kn238k>7gPmktkB;>bQ z>kal9jdzZTy=o7OBVH|?6X>2p5 z_D$g*-}v{q=S%nEqSsuE=e_G{Y%6cYBAP^g7{o9(Vm-cm-Is9S-UBP%FDQfv`XV<3 zi0;9v87HZQc7K|mUcio>J8=3-&cxS0c+0By=$7~0h?xfu;-Wvk0=1)uP)0wIGnk(e zWAAq#!q?vPMT~#x5jy|TPu-8XduH(T&s>Gl)*9v?ouwx3M)_7C$h4!CXqtQZsI32u z^0JZ%Pu@58T9XR}3c>JeLlm>W9fe}Bo7H6Okz%3#sm!c;GZ6(vRErffrTwgLnPHSP zlcZjFN{YMv*5DwAdzl!~n7)?QkdsR^DW?sM+vic%7HzfT2RHnXa<`4?MZ~QXt(g`k z@0q|IpSu&C`s(=Ex@}|VM+M^(`&Ts|M;v_wYN%lX8+`X0x8a{&`;Uw!EIT%x zv=L*+ttXN7bLdt(7#y}}H`{2Bba2s|o{c-My94z-t7lJs_%FBPzAxN`t>^E+$j;PZ{MY3>}+Z7G|M|Rv$*8R8es%QZ4nQetM8}B{L?4Jch=kr&!}AevT#P#is|xe38BjV8j2da2!-I8 ziV$&uCLKr=aMgWgmc5>Jq)Id~+nh&HjWV6ba5-QPO&77urWL77jZ@!erL#US7Ji_N z@1f6OeF`ymk7+FG+Q)@&xBzEgc@`!xfh{L*MRh|J3wxJR;Sbz>0Dt?!kE1+XLU%Dn z(q5ZY<8rP27{DE0`#$cwdF{pRHoMsW{r!i$woPYj#)YrD7<1hj0_qd24bj9lK|%iR z0*HN+dvVqa&PCjg@u`=85-U=+KgMyy@1Bav34>^kH~I9jA!_j0?s0E)CGr^mUew$fR9+g|0b>Zv`Yvc}uo z+%b2i?-yu%l>ir{Tj`qb&TCDrtvwFsaC_^rcV3ELe*Z6GwljzQ(+4oLp@vJ}eDSKU zcAwpxZe;>k8ZM!_er2R>I~FsT#>VqExM=fFCC@ z7053?9AP6#W2968mr|efPB+H*)Hu$3$$5C;=U;|_?Snt|y$VYaPW{NGIPR_Ip}Wu~ zEY!CjPHAmYf@mC#D}iq}BJ(en2PZ4u-3kV0vOg;5$pBa20u+M3)M1g|!${$ zQ;UYsPFv_C?H)+e07kcMM(w0AbXx6Re->~gt)-O!I;`R5m7ak7rdA1=k?#*P`o@h{kkSjRZ|*{5RrWjn~^&OW#@h*|AWfyQYs zIUO(j#EUVwbqEJ0#;Ja~I8a1ms*Z2{)i+_56-|+l{KnbP)denyuHeeuqWVIEqP^Yk z+>LD)Y{hLKy$yT6y`Kuy-Fbyd0b?hO;?j5h0!}&m6wL3N#iQTcht@)im@XSn-GFm` z=jmvsEi?|)(I_==%(+M5=r8^z?)~QdxaYdN@bH%(!rZ=jYV`q0dj-V+)vfGbdikleUzmM;C04&VaQ8}`P3*LDtzVqQ*(VS}1a+Jp^IOi?TL^M`HW3J9^ z-^8NWP@&t2F}`O%s_O@E$)7$KC%)z!Jn-qeu;+&RF>~h>U56{gaz*-KIe>~nls8r| z^309c`21rq{ETgArCrQCJjw0-LQS0+oY-+ffyr6!e$v>A3`8+`ICv={lN~mvDXkE= zV%T&N3LnGm2>MS(8bP@Nhtx1R!Of_SQ|Q!Sb72SOJJVQb&2bxBcO^3hrGW~L{q1K0 zwE*dC7X=|O7@_kNOq5+$D9wwg)0PUd)(=kO6@>cbx-W!6@As|A!bB6nvo=&ina9b1 zYu$Q{KvYKf3Z;{cyKOPuKvzuOUF?v++CXI!p2+9yridy9j0}uW>)i!MLU56GCq{Lo zO1%>u#eZ&g9+jagx(f+r9-Tr|46{nO1(z8KWQ=T@qFgIupgKU;xv45eR7HP(KbfVO#{9C{UJyR#de;mN4Wd3@M}6NMeZ%V38cO|TEKJNINmA0JR)oQd zt3#VQj-ksD`bPVy&Zil-(41*dQB`}UgR~n%4}=J6MMR@Tg#AUD4ppC8KxeiM6Dmsc zB_>6z5}q7o3{5C#T7!1V#qWtQ;Fk!R_=_u};^jhE2F*=Q_g|>+F+GpM-~d%pQEzDx zLI+f)33OtxIdVccg@x8UC)QoJt-3+fj zpB8##(Xn=I$XytOvL8^tmw*b+{U`}KU$bU)oujmL&-Vkqc(j5hEh{L2ApI&E4dx4S zl)R@aWsC*wrlv)e2w}it?Q*w{;@+9>QsW?93Q!vALouK(($qw#(l%J|ivL)NO)AwUnOXg^Lmls|J#JA(ZSwI=&xv_UWOkJ8(6=u8Wmc(!Vc|wy;(@U1bvl zbLzJ2!u}XDckIRblXoGGy2PwOF8EFZ?lG-$(};{rK;n72n!#?J64pe|8J<+FRd0+w zkA4tToOZX&_uexT^8s+;rpeq5^Pw4VBGCY755S6X7h%-mbnR5 z&hNA-^`&WP!Db|{-y2(-&+;59Qg%1V6-c!|1QhmAITA5%b;_OUA??20FEpWC>OTVR zD(<`8yLMyfGno4RUW`8bD1<{1Y`eRJJ#D2CWPa*}5uK3AZXv<9jC*MnNY6muzDX=;u-ALDbW%HS4%gxw(9n!OsMC%Q?_@us>0AH2BRNB9{T)+0y zloXGo@;waUvp^nrR~b%fy~FPN+z1II&81s=BSK{2ru298*sO=B((Dx?h+^^*uiksQ zl!Vkbi6=P4-TQ!}*;Ljc7I@4UMl(nljiHXLUB#(W*RRoS`7Xis3Qa+9dn4cSgoWio zrKF{dULVLG)La0%hh69jW7T=`NH?33RCis{7 zUBLq8Nm39vggc`8$_;)GCy*+mVfo;I)uiuPH4?zo})0h4WQv1`}VoAH!#Dgc=HwOeTneP&AeEt!Onl@)dD3S;JAroIpk7 z0h3T&6&QkEdw7{gtfgkfO2G~Zhtiqpwa#=N5h=tE)j|#+;N6>6#h;}t3xZX>9@_BpOB7!#WIl+MhStW5 zkPuC+>`*J2D&s2>h0$U%A5j{@8#b{XkM+5F(sM>Mv(tcNjVuu^U^pEYTOI=G%(K&) zi-BPppUrDt*@sIq-QG`GMIc?LE-V6e{zFhDIxmDYL4?fgbqi}k!_AFCMFE0`WO#@Y zn`>@GXQ74_7Vlrc`1SW-+bhn*eCZ(7DHDt$8Z{f~WnFV_#t(Vmpq5BB<=SXTR@2Z! zs{9H>$)`eY=*{NH_jrZ?;BJ}{!9oP&TH*JBSZ@$&tnam)=6yoLI@iz3%jPT_)+$Ez zx=qm-2U`4uKV9TCCCa+ghxlfd==FDIUZu$*+0>`EvT+wS3#e%88Iuv~eNmCJnT=VJ z0Pvl|lwf$qcYKxmmdd%-Z#KhQ(MZ!!--9usHh9uN)bj92=Qee$K|PRk_cFR&CU(|} z7F1Xvq1hp(BAt8y2(I(I`jE}AYe>ny7*V{EfZKW*#;sTzin&&SsDClZ?t0b(`)_^# zwNp2taOM!Y6Z0sVP-gAs0uuVjxTnd)Ou8Z4sZ?xf8`8~Ba|08(MMyx;YpG@MWBrxP z-%W+D7)$cpHze2cxkkon`ze|#mt5yA{p^z|uSK#QZUj4Jhd7l%J657#N?TcEpO;!c z2bpK&+5ALy3rTsYY(e~VfNXfCWfz0{CwlKv_tKj=+MLZ003&V(SH&eFuz_r%#50nt z^tsN35Kh>s0*`uv^SHCF4D@!|(M@%#dj_@8`!Wl#s<~Z8yUGhJ^9Ltjo0kB(?Vk?a zm68Ll{mqiGtCn{*sb-(b6aJo-Cf&*nr%4p_fPz^xxc_6{!mjO?ptNBS-TiaaZX*>^ zcIsyyNyKY%w-(u$1#fx-(v%5LO_VvsNu~vTsVJ2Ss6bwm^KGq?@u^k@8YHih%q%l&8!>z%!xn<%rD(;VYm31iyZc_-l9eA3q?c^`lU z-N@HrAI`PqP^vdJ)@I(f=VAup*YT%0*efOz+w1(F`e+(T%)gZ(jt+j2rlH0W>QmGM zBgA@%17rptg(j*uV}LZE?fJJWh0@3-?gHt|UjU^?@&-KQ-n=PH>Mu;;_KU&`yb&@R~REwP7}2eOKO(vjlZGNXiczp@2clZ>0>tX5qZbYG-@w zoK)HD#wFsyIsb)y_$^HA000*SNkl&@FKehy6{ry66XMAXZ5BT zXbk8Y+UX)Xw!>^cVZ3A*k@w%+iz>k4U32I>xPVP(?0^~x(QUPP#8|E-L3z>wau}cL z)z8+aPXq)`#960vg)N>SpHUF4<-uyx1EGz_F6)ndwh>@|d zsfUj&2XwJ~Rt%d7dm@k6vT3$-jS5{iP*E!^+7u0&&@}jD8TmkoSDVoXl=mh)f6)7U zYfy-s26SdP{_Y21CO^x<*Ie=l34e59xl{3h&IHg6RI$E{PboiXnphQ-sgNB>QMB_X zL)X2_5Nv~^fXTb|qW#V$cD(E)4D1|3v$24<9`kHtnKl_n6LS(a*-3%mV`(;~L1hRR z`*v=YiGSV?%0VwH1JCL(GSf|wS+Rcbiyu##SvRL)d6AIcBCc5UQ3 z2V0o>#-k{QZmh#PgaaXxB!=mV#?yWeElsQCzdlFSx(A1Nhu>@M)N*?dcb061v}6$t z6cG&+5cF$=)d;#$1gasz{s=)|KyZXh&{Os%P1(P7La0;8esJWP%AiK$(`(9`>})_8 zr?>#A+_1uY=19~%muSOXnkf@uDEcJx?0%=gh0a$rG`xmiBLizlTP0`)36*v`0dnTq zz1|6&E3JqXYqv!hG_^&;rdR~?{hTD$b&5Joluk)#UGU*FURQ*Z3#xqUDIfXTY(?@( zIDW>+hPIi>ho8EdH^<6nMaJI?sNd`Lt6ohAUrMh%09|+iRH@IG6vFg~+JkBUEws=v z-K;m9&9Er;RJGuCUWf<>9ApQ2>el^e-ZzVCt%mBBAq4#akNHof?9&LQ=b;4ueWW9U zk3fxpr&e;PNt8m@0z^Ya=&D9g4N&Q;p_CRdH?e^F_yUs2F3Ni%gnJd{XXj9#UVvSQ zQ7u+c9Ie7sEmVI5R08si(!}t@q~*Y_&6a@R3M)C+G}Ba8@WLKg>|rMBO)A)LHByV? zOnd65Xv*R`XvXDdDQjUp92Jo7)(eF+r7#z4BL1SKj;V>tSp&r&a|dbWN+hzm82_=J z<&n?FJ`x<%QlD}^wW)xHa6?yCp} zuI8(BV~0%Um>SRe4=J>R)ge@pNc#sXRS&{mkyIRMDtA|g0@#wl)V&iln`7*xtr$If z69$eSh8n9NHVJ(xk-T^WjK{%}4D(I9x(&q=T(bI}&C zP#GOWp^w3&gLkZ@0ZG?bt^OZoayb_6a~v{O7G`& z3BzR^lw1dIiM$nABaPb=rkvKxMfigu+~?}(1G=>1jgHJ}l9+R$X^O2_kGak?=92~JG@@QA z%H@}x>2lmQBc0u20WKZLnkHNYnA+R`Zdrh)1?Kpg+(vO+z4hxiQsH{tb$d-jfMHNf zN~k1d%+Ji@;JyP08Z1C*OTgh$ZmK|8h!?3xWqIhMAjeA@eOXcX)E1*D?uxS*9j2-E zj}Bt!?9CW{(RSeJ{fM0)C0$_FX-7kNpZ2(*WnkHZPo5OO4J9pMbd)=H8eg!xP^K3& zEO$T+1Z~ikN882%lXWFhCz7$16l;c#dFpi)j{v$~Uf`yKPI8iDa|m4925wJ*0kxVCxfrwgTY!<^qP&TT#{w)5pKB&khtPb z_?=K5bnJ0%gG5p}RkYgTR&!@CO~>eeGqRvP1~oOk&s0 zwQ`fzBNv*u36;dytTto5GlP0}-i?qUL8BSF2?rNSTEmgRxjJ5tL%5ylv|=Lc3zGqA z9!=&B@u{DY%r(?nhILa)Pz0A;O+UEN_@XVDBr3wj?y1hr*Yg6+9=S?;NmJ{E&@-~U zKvFJUT3Z-#AI1UI0t#bg3_N=iO3&Yb=o#x^Dk1fsv9mGKMnWD(5ONCA2ou?T7vDzE z<#n5~PbhVtGG#ui>&ZGmaMAvME7;m*n%#PHVY3lf@K) zy;?;z+9LzHf{p{LCB$Z8g37{jD?3!lxYH#)>csz0x|ewOVk^?PoyQ;RZ@AAh<=NpL7)P)7t;zX}PT&(Bk){d?YJ&pSfl5G|-<(C` zQ~S{U$|S3_U@~gChCg zISuJKI97i5GQ3=$$x!W8nJqa@sFu7U-^5u2-T7rjk&kJ>wr+r({R6^Wb~{p-Y3;{+vIv+W0%v}m zsWohpo%W)iyJDJY*kZ~u=FD)Ka01;?h}_EfnaD9WN@Aap^Q@%deLy2xj5K7bdKxkn z<}o3=q9sk%K962Yd0u*Nol&V&hD^wUr#X#^9A7WDA|9Dw7eLl^UbLf|#=5D1>X{p% zFI$K3+!2@^eXvD^4iYTX+X!Y;XlV}UG-6k%Z9P-#&8+9|W0}C@wW6YequvJ3owaJa zjpfaR@_*@R!lizqM9%{L!wp3*mR$FD=WJesc;arLIEf&f=$Rk)<@%Ru!uS!OG5~BT z0B;=!-naU&A!M3x_2UQ7(Yq1Yfj(V1&*eJWww>jZd;3%IFlNgdCrkHfm61ua1w~H_ zWjouCpYr3K0%Un&Ewg#y;V3`BigK3%am&Qb>|SfG|L|+8RL*Y~ZRy{0X}=w>RaNhi z<+srj+LSF4hTV)Dr|rf-Z*0V*xPIwC_V zQE|)~jv5?MU;=`5h=$~_CDEFsv5bJDE>jVwQd}=AZR53{f=X=9=QiS-i2!)tP-r4_ z4V+H19E}_H!wjRvqCEwCH!?y2JuW1V$W>V|!+5Nu(NdLl2h%1$cLhDRv+n^JOK`Oa z=q?a0Mkt&%i1K-xfb(lm=M14f;3i@h(Q?Q`qs_jt^H;mMl$rR^ta|f8>4k7@30$-J z&=U9s*;G}-Ok!v^jL1d*MU7&y0(4>&n-<}|78Y;03!QcK=i{AFk{^_Sl3l8UiBs!QCp7$t_UPwa{a~;7qcos;Ib2=9Ng5sVkaM z5}=3)rsy~kL6YEw1SO>+Yz|l~!8<}Zanz9+a(jHH$L6ACNMv(-V2{+uUjhvedHt4Ww@IcMw3f!1)}9+1=F%nQ-GZU$|E6c zUxf10#}J*k88b(Op#m>IQ5BZaS5^7%?rM(Tz?|HPV?(d|X zdh#N?e(X~?Z#%Hq_oIbyun4%W4}bAJ;7<;3;P+w7F(LccfGXY%6JHK2oC@2%7J6_O zY;n|OQEX6TU(X@_?!p40`#2xa30bPicT^7Yk7H^@=DC1K%=g_`Iix5f!2p!K3n^ZS z1Q7vfUI-KTwyhw9F~GAB+xMf519blbo7xcPyGbnsggO<~CHf2pFks(NOT8{R^r^px zjAOFDsB%F9dZx^G$+EuhN-v-VWB>SrD7P!9&H;`4??>17)bc}Nt=W8Jj)#Fl1(Py!HIz)vtV~f=+}DTt?raHBTQ`b!WKl0R5sVeF>4@zpk5mwsHTFjHh)oLR3hMiG z&dSJNjgGrM7d|Ax?EEmSP0Fy9S)J0PO7m z?LI)Yd$2|tZ9=Dg0+avyR*q=CadnQ9tZ?B%xvcU=Y0Op|D0mLJY6~z{0v<5UE8=mxT(L+ zZb8@!T>-ir#WKe-k1^y`7R*&za>z(ig*p$BC}4G1?=%+Oiqk^R8XbiHuZ~8uu2@^ zmH`50UJF@21n^63RyHZ<+H-U`>%SnsP65L9lDm-$D__Hp+ttLzY&L(Qlhs-Gw&0l( zj#bc^gN=R@mg=?>f>xEKqE%vNZEeLhj7!>%@mAa3pzrg$Ugi=0DG;`4?lLt98j2*Xnnl|`agw3oTH?w;rY_N#WWBk3MyQsqn<`Z< zoKjL+o22f8XBFxfB&7yLQlYD9jkS&dm2aTo@uKtLJJfQ1IKf?8W( z{`GaS%{MJaK27;f`n<~=A5eO+gs??bD>)G7X!MsL?4sw;id~PkE>tR|ENpinlSGBdqV(ze8SlH#P|f7qcv_@q8Zw$F$D5I%OAs zKUj26A^e=ja-NE#T$7W&e462TN?Nj@z!?QHMN-eQx+pJ7-?9ywB37)1ZKEWD1WqFv zP@|c2%P%S@#8uTaO_J#CR70l-&S>r! zPx6aWO>I`9*htKX!87{^^AoHlj%#%yqbGMw5qF@K{1NlNB6`#2fQ|o=m+*)Ml=R#- z20ac-!OK*vvdAxE7tZ&MO_vjn+ZPoauHiW+dw<}dx0AWsUSDd(eFCRIJR^?Ob%7qo zG5lsa<`J=SD0~5xcv_ky92J`0de1|z!UCdZcvdxz_XDU+ita=9t}nh zNG%(|E3(dvJVC(;pFMkJize@*%z!t}fEgoOr5rP@ipF)n(AX$V`A6NWU-z}Mi^45) zn;`FD`98g<@$Y$c?;8|1x7~Td|C?nVOoaC{aB8UJ6Gq%m*N+wPL4DRd%Dh=+({7|b z=yj-l`EH0_+Fz!_xAZtg&4SO$UC(mKw9C2jN?p|7Y$Mwow7lFI@D~Jzz!KhiNzwoS N002ovPDHLkV1l#XP=5db 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..24982ee 100644 --- a/tox.ini +++ b/tox.ini @@ -1,6 +1,6 @@ # 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] @@ -8,6 +8,7 @@ python = 3.8: py38 3.9: py39 3.10: py310 + 3.11: py311 [gh-actions:env] PLATFORM = @@ -23,12 +24,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 From 1387ea5c45bbd15f516503503d5db01b8fc9a45d Mon Sep 17 00:00:00 2001 From: Yu Liu <70626217+yuliu96@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:03:04 +0100 Subject: [PATCH 2/8] Update README.md --- README.md | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index c44cd53..5e4d907 100644 --- a/README.md +++ b/README.md @@ -27,17 +27,22 @@ 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` (current version) requires **BaSiCPy ≥ 2.0**. + +If you need compatibility with **BaSiCPy < 2.0**, please use an earlier plugin version: + + pip install napari-basicpy==0.1.0 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 From 91b061e2bd81e5de64923987a5d95903595aa95e Mon Sep 17 00:00:00 2001 From: Yu Liu <70626217+yuliu96@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:07:35 +0100 Subject: [PATCH 3/8] Update repository links in README.md --- README.md | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/README.md b/README.md index 5e4d907..75f0e60 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 @@ -69,7 +69,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/ From 523eb2e88d60d081781a3db76801088d79b12396 Mon Sep 17 00:00:00 2001 From: Yu Liu <70626217+yuliu96@users.noreply.github.com> Date: Mon, 9 Mar 2026 16:12:39 +0100 Subject: [PATCH 4/8] Update README.md --- README.md | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/README.md b/README.md index 75f0e60..cd9c7e3 100644 --- a/README.md +++ b/README.md @@ -33,13 +33,10 @@ You can install `napari-basicpy` via [pip]: ### Compatibility -`napari-basicpy` (current version) requires **BaSiCPy ≥ 2.0**. +`napari-basicpy` (>=1.0.0) requires **BaSiCPy ≥ 2.0**. If you need compatibility with **BaSiCPy < 2.0**, please use an earlier plugin version: - pip install napari-basicpy==0.1.0 - - To install latest development version : pip install git+https://github.com/peng-lab/napari-basicpy.git From 89c612963bfc1983675b54cdab6759882b6d383d Mon Sep 17 00:00:00 2001 From: Yu Liu Date: Mon, 9 Mar 2026 16:20:27 +0100 Subject: [PATCH 5/8] fix ci --- .github/workflows/test_and_deploy.yml | 2 +- pyproject.toml | 3 +-- tox.ini | 1 - 3 files changed, 2 insertions(+), 4 deletions(-) diff --git a/.github/workflows/test_and_deploy.yml b/.github/workflows/test_and_deploy.yml index 7f7b303..d798337 100644 --- a/.github/workflows/test_and_deploy.yml +++ b/.github/workflows/test_and_deploy.yml @@ -20,7 +20,7 @@ jobs: strategy: matrix: platform: [ubuntu-latest] - python-version: ["3.8", "3.9", "3.10", "3.11"] + python-version: ["3.9", "3.10", "3.11"] steps: - uses: actions/checkout@v4 diff --git a/pyproject.toml b/pyproject.toml index 541fd2b..3f3782e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -26,7 +26,7 @@ name = "napari-basicpy" dynamic = ["version"] description = "BaSiCPy illumination correction for napari" readme = "README.md" -requires-python = ">=3.8" +requires-python = ">=3.9" license = { text = "BSD-3-Clause" } authors = [ { name = "Yu Liu", email = "liuyu9671@gmail.com" }, @@ -41,7 +41,6 @@ classifiers = [ "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", "Programming Language :: Python :: 3.11", diff --git a/tox.ini b/tox.ini index 24982ee..9dedc2c 100644 --- a/tox.ini +++ b/tox.ini @@ -5,7 +5,6 @@ isolated_build=true [gh-actions] python = - 3.8: py38 3.9: py39 3.10: py310 3.11: py311 From c65e767f37500e51b49e1109c91dd3e8e6e858f6 Mon Sep 17 00:00:00 2001 From: Yu Liu Date: Mon, 9 Mar 2026 16:28:00 +0100 Subject: [PATCH 6/8] Update pyproject.toml --- pyproject.toml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 3f3782e..dd02c9e 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -50,7 +50,8 @@ dependencies = [ "numpy", "qtpy", "aicsimageio", - "basicpy>=2.0.0" + "basicpy>=2.0.0", + "matplotlib", ] [project.urls] From 7dd81da5610509ddf6c01ee5647dc8df8cab05fb Mon Sep 17 00:00:00 2001 From: Yu Liu Date: Mon, 9 Mar 2026 16:37:17 +0100 Subject: [PATCH 7/8] Update test_widget.py --- src/napari_basicpy/_tests/test_widget.py | 30 ++++++++++-------------- 1 file changed, 13 insertions(+), 17 deletions(-) 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 From 842d38b659bdf01db69feef03ce70a88e0d4782e Mon Sep 17 00:00:00 2001 From: Yu Liu Date: Mon, 9 Mar 2026 16:51:25 +0100 Subject: [PATCH 8/8] Update test_sample_data.py --- src/napari_basicpy/_tests/test_sample_data.py | 8 -------- 1 file changed, 8 deletions(-) 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