diff --git a/.github/workflows/protected_branches.yml b/.github/workflows/protected_branches.yml index 758c5105..c5b9694d 100644 --- a/.github/workflows/protected_branches.yml +++ b/.github/workflows/protected_branches.yml @@ -61,7 +61,6 @@ jobs: - name: Build Conda Package run: | # boa uses mamba to resolve dependencies - conda install -y anaconda-client boa cd conda.recipe VERSION=$(versioningit ../) conda mambabuild --output-folder . -c conda-forge . || exit 1 conda verify noarch/imars3d*.tar.bz2 || exit 1 diff --git a/.gitignore b/.gitignore index 23352ed3..bff69f29 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,6 @@ +# direnv +.envrc + *.pyc *~ .ipynb* diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index eaf85ac6..4d2f1037 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -29,14 +29,6 @@ repos: hooks: - id: black args: ['--line-length=119'] - - repo: https://github.com/PyCQA/flake8 - rev: 6.0.0 - hooks: - - id: flake8 - exclude: | - (?x)^( - ^docs/conf.py - )$ - repo: https://github.com/adrienverge/yamllint.git rev: v1.31.0 hooks: diff --git a/conda.recipe/meta.yaml b/conda.recipe/meta.yaml index f22e556e..98175aec 100644 --- a/conda.recipe/meta.yaml +++ b/conda.recipe/meta.yaml @@ -1,22 +1,25 @@ # load information from setup.cfg/setup.py -{% set data = load_setup_py_data() %} -{% set license = data.get('license') %} -{% set description = data.get('description') %} -{% set url = data.get('url') %} +{% set pyproject = load_file_data('pyproject.toml') %} +{% set project = pyproject.get('project', {}) %} +{% set license = project.get('license').get('text') %} +{% set description = project.get('description') %} +{% set project_url = pyproject.get('project', {}).get('urls') %} +{% set url = project_url.get('homepage') %} # this will get the version set by environment variable {% set version = environ.get('VERSION') %} -{% set version_number = environ.get('GIT_DESCRIBE_NUMBER', '0') | string %} +{% set version_number = version.split('+')[0] %} +{% set build_number = 0 %} package: name: imars3d - version: {{ version }} + version: {{ version_number }} source: path: .. build: noarch: python - number: {{ version_number }} + number: {{ build_number }} string: py{{py}} script: {{ PYTHON }} -m pip install . --no-deps --ignore-installed -vvv diff --git a/environment.yml b/environment.yml index 5f55323a..f04c4350 100644 --- a/environment.yml +++ b/environment.yml @@ -2,29 +2,44 @@ name: imars3d channels: - conda-forge dependencies: + # -- Runtime + # base + - python + - versioningit + - toml + # compute - astropy - tomopy - - dxchange - - jsonschema - - panel>=0.14.2 - - param - - pyvista - - pydocstyle + - algotom + # plot - holoviews - bokeh - datashader - hvplot - - build - - toml - - conda-build - - conda-verify - - pytest - - pytest-cov + # GUI + - panel<1.3 + - param<2 + - pyvista + # IO + - dxchange + - jsonschema + # -- Development + # utils - pre-commit + # packaging + - anaconda-client + - boa + - conda-build < 4 + - conda-verify + - python-build + # doc + - pydocstyle - sphinx - sphinx_rtd_theme - - versioningit - - pip + # test + - pytest + - pytest-cov + # pip - pip: - check-wheel-contents - pytest-playwright diff --git a/pyproject.toml b/pyproject.toml index d69b7909..bed32c1a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,13 +1,31 @@ +[project] +name = "imars3d" +description = "Neutron imaging data analysis at ORNL" +dynamic = ["version"] +requires-python = ">=3.10" +license = { text = "BSD 3-Clause License" } +dependencies = [ + "astropy", +] + +[project.urls] +homepage = "https://github.com/ornlneutronimaging/iMars3D" + +[project.scripts] +imars3dcli = "imars3d.backend.__main__:main" + [build-system] -requires = ["setuptools >= 40.6.0", "wheel", "toml", "versioningit"] +requires = [ + "setuptools >= 40.6.0", + "wheel", + "toml", + "versioningit" +] build-backend = "setuptools.build_meta" -[tool.black] -line-length = 119 - [tool.versioningit.vcs] method = "git" -default-tag = "5.0.0" +default-tag = "1.0.0" [tool.versioningit.next-version] method = "minor" @@ -20,13 +38,49 @@ distance-dirty = "{next_version}.dev{distance}+d{build_date:%Y%m%d%H%M}" [tool.versioningit.write] file = "src/imars3d/_version.py" +[tool.setuptools.packages.find] +where = ["src"] +exclude = ["tests*", "scripts*", "docs*", "notebooks*"] + +[tool.setuptools.package-data] +"*" = ["*.yml", "*.yaml", "*.ini", "schema.json"] + [tool.pytest.ini_options] -pythonpath = [ - ".", "src", "scripts" -] +pythonpath = [".", "src", "scripts"] testpaths = ["tests"] python_files = ["test*.py"] -norecursedirs = [".git", "tmp*", "_tmp*", "__pycache__", "*dataset*", "*data_set*"] -markers = [ - "datarepo: mark a test as using imars3d-data repository" +norecursedirs = [ + ".git", "tmp*", "_tmp*", "__pycache__", + "*dataset*", "*data_set*", + "*ui*" + ] +markers = ["datarepo: mark a test as using imars3d-data repository"] + +[tool.pylint] +max-line-length = 120 +disable = ["too-many-locals", + "too-many-statements", + "too-many-instance-attributes", + "too-many-arguments", + "duplicate-code" +] + +[tool.coverage.run] +source = [ + "src/imars3d/backend" +] +omit = [ + "*/tests/*", + "src/imars3d/__init__.py", + "src/imars3d/ui/*" +] + +[tool.coverage.report] +fail_under = 60 +exclude_lines = [ + "pragma: no cover", + "def __repr__", + "def __str__", + "if TYPE_CHECKING:", + "if __name__ == .__main__.:" ] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index db36419d..00000000 --- a/setup.cfg +++ /dev/null @@ -1,67 +0,0 @@ -[metadata] -name = imars3d -version = 0.1.3 -author = iMars3D and SCSE team -description = Neutron imaging data analysis at ORNL -keywords = Neutron imaging -long_description = file: README.md, LICENSE -license = BSD 3-Clause License -url = https://github.com/ornlneutronimaging/iMars3D -project_urls = - Bug Tracker = https://github.com/ornlneutronimaging/iMars3D/issues -classifiers = - License :: OSI Approved :: BSD License - Operating System :: OS Independent - Programming Language :: Python :: 3 - Programming Language :: Python :: 3.8 - Programming Language :: Python :: 3.9 - Programming Language :: Python :: 3.10 - -[options] -include_package_data = True -packages = find: -scripts = - scripts/reduce_CG1D.py -python_requires >= 3.8 -# install_requires is skipped for the following reasons: -# 1. tomopy is not available on PyPI -# 2. readthedoc invokes pip install with `--upgrade`, which can mess up the -# dependency resolved by conda in previous step. -# 3. we will only use wheel to check package content, and the pacakge distribution -# is done by conda. - -[options.packages.find] -where = src -exclude = - tests - notebooks - -[options.entry_points] -console_scripts = - imars3dcli = imars3d.backend.__main__:main - -[options.package_data] -* = schema.json - -[options.extras_require] -tests = pytest - -[aliases] -test = pytest - -[flake8] -ignore = E203, E266, E501, E731, W503 -exclude = conda.recipe/meta.yaml -max-line-length = 120 - -[coverage:run] -source = src/imars3d -omit = - */tests/* - src/imars3d/__init__.py - src/imars3d/ui/* - -[coverage:report] -fail_under = 60 -exclude_lines = - if __name__ == "__main__": diff --git a/setup.py b/setup.py deleted file mode 100644 index 789e35d1..00000000 --- a/setup.py +++ /dev/null @@ -1,6 +0,0 @@ -# this file is necessary so conda can read the contents of setup.cfg using -# its load_setup_py_data function -from setuptools import setup -from versioningit import get_cmdclasses - -setup(cmdclass=get_cmdclasses()) diff --git a/src/imars3d/backend/corrections/beam_hardening.py b/src/imars3d/backend/corrections/beam_hardening.py new file mode 100644 index 00000000..9338fabe --- /dev/null +++ b/src/imars3d/backend/corrections/beam_hardening.py @@ -0,0 +1,97 @@ +#!/usr/bin/env python3 +# -*- coding: utf-8 -*- +"""Imaging correction for beam hardening.""" +import logging +import param +import numpy as np +from imars3d.backend.util.functions import clamp_max_workers +from multiprocessing.managers import SharedMemoryManager +from functools import partial +from tqdm.contrib.concurrent import process_map +from algotom.prep.correction import beam_hardening_correction as algotom_beam_hardening_correction + +logger = logging.getLogger(__name__) + + +class beam_hardening_correction(param.ParameterizedFunction): + """Imaging correction for beam hardening. + + Parameters + ---------- + arrays: np.ndarray + The image stack to be corrected for beam hardening, must be normalized to 0-1. + q: float + The beam hardening correction parameter, must be positive. + n: float + The beam hardening correction parameter, must be greater than 1. + opt: bool + If True, correction biased towards 1.0, else correction biased towards 0.0. + max_workers: int + The maximum number of workers to use for parallel processing. + tqdm_class: panel.widgets.Tqdm + Class to be used for rendering tqdm progress + + Returns + ------- + np.ndarray + The corrected image stack. + """ + + arrays = param.Array( + doc="The image stack to be corrected for beam hardening, must be normalized to 0-1.", + default=None, + ) + q = param.Number( + doc="The beam hardening correction parameter.", + default=0.005, + bounds=(0, None), + ) + n = param.Number( + doc="The beam hardening correction parameter.", + default=20.0, + bounds=(1, None), + ) + opt = param.Boolean( + doc="If True, correction biased towards 1.0, else correction biased towards 0.0.", + default=True, + ) + max_workers = param.Integer( + doc="The maximum number of workers to use for parallel processing, default is 0, which means using all available cores.", + default=0, + bounds=(0, None), + ) + tqdm_class = param.ClassSelector(class_=object, doc="Progress bar to render with") + + def __call__(self, **params): + """Perform the beam hardening correction.""" + logger.info("Performing beam hardening correction.") + # type check & bounds check + _ = self.instance(**params) + # sanitize arguments + params = param.ParamOverrides(self, params) + # set max_workers + self.max_workers = clamp_max_workers(params.max_workers) + logger.debug(f"max_worker={self.max_workers}") + + if params.arrays.ndim == 2: + return algotom_beam_hardening_correction(params.arrays, params.q, params.n, params.opt) + elif params.arrays.ndim == 3: + with SharedMemoryManager() as smm: + shm = smm.SharedMemory(params.arrays.nbytes) + shm_arrays = np.ndarray(params.arrays.shape, dtype=params.arrays.dtype, buffer=shm.buf) + np.copyto(shm_arrays, params.arrays) + # mp + kwargs = { + "max_workers": self.max_workers, + "desc": "denoise_by_bilateral", + } + if self.tqdm_class: + kwargs["tqdm_class"] = self.tqdm_class + rst = process_map( + partial(algotom_beam_hardening_correction, q=params.q, n=params.n, opt=params.opt), + [shm_arrays[i] for i in range(shm_arrays.shape[0])], + **kwargs, + ) + return np.array(rst) + else: + raise ValueError("The input array must be either 2D or 3D.") diff --git a/src/imars3d/backend/corrections/ring_removal.py b/src/imars3d/backend/corrections/ring_removal.py index 7ca9910e..dd39c6e3 100644 --- a/src/imars3d/backend/corrections/ring_removal.py +++ b/src/imars3d/backend/corrections/ring_removal.py @@ -250,7 +250,7 @@ def _remove_ring_artifact( correction_range=correction_range, ), [shm_arrays[:, sino_idx, :] for sino_idx in range(shm_arrays.shape[1])], - **kwargs + **kwargs, ) rst = np.array(rst) for i in range(arrays.shape[1]): diff --git a/tests/unit/backend/corrections/test_beam_hardening.py b/tests/unit/backend/corrections/test_beam_hardening.py new file mode 100644 index 00000000..b3e3b2f0 --- /dev/null +++ b/tests/unit/backend/corrections/test_beam_hardening.py @@ -0,0 +1,71 @@ +"""Unit test for beam hardening correction""" +#!/usr/bin/env python +import numpy as np +import pytest +from imars3d.backend.corrections.beam_hardening import beam_hardening_correction + + +@pytest.fixture(scope="module") +def fake_beam_hardening_image() -> np.ndarray: + """Use a checker board image as the fake beam hardening image + + Returns + ------- + np.ndarray + A checker board image stack + + NOTE + ---- + As of 02-15-2024, the dev team did not receive needed testing data + for beam hardening correction, a synthetic image is used for testing. + """ + np.random.seed(42) + image_stack = [] + image_size = 255 + image = np.zeros((image_size, image_size)) + for i in range(image_size): + for j in range(image_size): + if (i + j) % 2 == 0: + image[i, j] = 1 + # flip the image to create a stack + for _ in range(10): + if np.random.rand() > 0.5: + image = np.flip(image, axis=0) + else: + image = np.flip(image, axis=1) + image_stack.append(image) + return np.stack(image_stack, axis=0) + + +def test_beam_hardening_correction(fake_beam_hardening_image): + """Test beam hardening correction""" + # test with incorrect array dim + with pytest.raises(ValueError): + beam_hardening_correction( + arrays=np.array([0, 1, 2, 3]), + q=0.005, + n=20.0, + opt=True, + ) + # test with single image + corrected_image = beam_hardening_correction( + arrays=fake_beam_hardening_image[0], + q=0.005, + n=20.0, + opt=True, + ) + assert corrected_image.shape == (255, 255) + # test the correction + corrected_image = beam_hardening_correction( + arrays=fake_beam_hardening_image, + q=0.005, + n=20.0, + opt=True, + ) + assert corrected_image.shape == (10, 255, 255) + assert corrected_image.dtype == np.float64 + assert np.all(corrected_image >= 0) + + +if __name__ == "__main__": + pytest.main([__file__]) diff --git a/tests/unit/backend/diagnostics/test_rotation.py b/tests/unit/backend/diagnostics/test_rotation.py index aecc00fc..a02a6205 100644 --- a/tests/unit/backend/diagnostics/test_rotation.py +++ b/tests/unit/backend/diagnostics/test_rotation.py @@ -73,7 +73,7 @@ def test_differrent_centers(center_ref): # this is using default number of pairs (1) center_calc = find_rotation_center(arrays=projs, angles=OMEGAS, in_degrees=False) # with atol - center_calc2 = find_rotation_center(arrays=projs, angles=OMEGAS, in_degrees=False, atol=0.5) + center_calc2 = find_rotation_center(arrays=projs, angles=OMEGAS, in_degrees=False, atol_deg=0.01) # verify # NOTE: # answer within the same pixel should be sufficient for most cases diff --git a/tests/unit/backend/diagnostics/test_tilt.py b/tests/unit/backend/diagnostics/test_tilt.py index 17678515..b73b7200 100644 --- a/tests/unit/backend/diagnostics/test_tilt.py +++ b/tests/unit/backend/diagnostics/test_tilt.py @@ -296,7 +296,7 @@ def test_tilt_correction(): # verify np.testing.assert_allclose(projs_corrected, projs_ref) # case 1a: null with the progress bar - projs_corrected = tilt_correction(arrays=projs_ref, rot_angles=omegas, tqdm_class=Tqdm()) + projs_corrected = tilt_correction(arrays=projs_ref, rot_angles=omegas) np.testing.assert_allclose(projs_corrected, projs_ref) # case 2: small angle tilt tilt_inplane = np.radians(0.5)