From a75b78f1cbff43c56cd27f61e72fe07bfa592990 Mon Sep 17 00:00:00 2001 From: epapoutsellis Date: Thu, 29 Jan 2026 12:17:58 +0000 Subject: [PATCH 01/10] add bm3d, not tests --- .../optimisation/functions/BM3DFunction.py | 70 +++++++++++++++++++ 1 file changed, 70 insertions(+) create mode 100644 Wrappers/Python/cil/optimisation/functions/BM3DFunction.py diff --git a/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py b/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py new file mode 100644 index 0000000000..af3580b100 --- /dev/null +++ b/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py @@ -0,0 +1,70 @@ +import numpy as np +from cil.optimisation.functions import Function +import warnings + +try: + from bm3d import bm3d, BM3DStages + _HAS_BM3D = True +except ImportError: + bm3d = None + BM3DStages = None + _HAS_BM3D = False + + warnings.warn( + "Optional dependency 'bm3d' is not installed. Install via `pip install bm3d.", + RuntimeWarning, + stacklevel=2, + ) + + +class BM3DFunction(Function): + """ + PnP 'regulariser' whose proximal applies BM3D denoising. + + Use in PnP-ISTA/FISTA + Maybe add damping: (1-gamma) z + gamma * BM3D(z). + """ + + def __init__(self, sigma, profile="np", stage_arg=BM3DStages.ALL_STAGES, + positivity=True): + + + # self.gamma = float(gamma) # damping in (0,1] + # if not (0.0 < self.gamma <= 1.0): + # raise ValueError("gamma must be in (0,1].") + self.sigma = sigma + if self.sigma<=0: + raise ValueError("pos") + self.profile = profile + self.stage_arg = stage_arg + self.positivity = positivity + + super(BM3DFunction, self).__init__(L=None) + + def __call__(self, x): + # does not exist, return 0 for now, add warning + return 0.0 + + + def _denoise(self, znp: np.ndarray) -> np.ndarray: + z = np.asarray(znp, dtype=np.float32) + # BM3D expects sigma as noise std (same units as the image) + return bm3d(z, sigma_psd=self.sigma, profile=self.profile, + stage_arg=self.stage_arg).astype(np.float32) + + def proximal(self, x, tau, out=None): + + ## TODO asarray for SIRF? + z = x.array.astype(np.float32, copy=False) + den_bm3d_np = self._denoise(z) + + # damping/relaxation (oscillations) + # u = (1.0 - self.gamma) * z + self.gamma * d + + if self.positivity: + np.maximum(den_bm3d_np, 0.0, out=den_bm3d_np) + + if out is None: + out = x * 0.0 + out.fill(den_bm3d_np) + return out \ No newline at end of file From 915a43600b415d8b07802c189bfe4b36840e3278 Mon Sep 17 00:00:00 2001 From: epapoutsellis Date: Thu, 29 Jan 2026 12:38:19 +0000 Subject: [PATCH 02/10] add init --- Wrappers/Python/cil/optimisation/functions/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/Wrappers/Python/cil/optimisation/functions/__init__.py b/Wrappers/Python/cil/optimisation/functions/__init__.py index 762c4d29ff..b78d366177 100644 --- a/Wrappers/Python/cil/optimisation/functions/__init__.py +++ b/Wrappers/Python/cil/optimisation/functions/__init__.py @@ -40,4 +40,5 @@ from .SVRGFunction import SVRGFunction, LSVRGFunction from .SAGFunction import SAGFunction, SAGAFunction from .AbsFunction import FunctionOfAbs +from .BM3DFunction import BM3DFunction From 81eff0b8b8a979d922324d13ccd3e3bffe7e9535 Mon Sep 17 00:00:00 2001 From: epapoutsellis Date: Thu, 29 Jan 2026 13:01:07 +0000 Subject: [PATCH 03/10] add bm3d for testing --- recipe/meta.yaml | 1 + 1 file changed, 1 insertion(+) diff --git a/recipe/meta.yaml b/recipe/meta.yaml index ebecb34f75..2365fdf5a2 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -14,6 +14,7 @@ test: - pip - python-wget - cvxpy # [linux] + - bm3d # [linux] - scikit-image - tomophantom 2.0.0 # [linux] - tigre 2.6 From 666d73cf81fcd59b6bdad583641f7695b499ddbe Mon Sep 17 00:00:00 2001 From: epapoutsellis Date: Thu, 29 Jan 2026 13:47:59 +0000 Subject: [PATCH 04/10] fix args when bm3d is not installed --- Wrappers/Python/cil/optimisation/functions/BM3DFunction.py | 4 +++- pyproject.toml | 2 +- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py b/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py index af3580b100..33df666593 100644 --- a/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py +++ b/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py @@ -5,9 +5,11 @@ try: from bm3d import bm3d, BM3DStages _HAS_BM3D = True + ALL_STAGES = BM3DStages.ALL_STAGES except ImportError: bm3d = None BM3DStages = None + ALL_STAGES = None _HAS_BM3D = False warnings.warn( @@ -25,7 +27,7 @@ class BM3DFunction(Function): Maybe add damping: (1-gamma) z + gamma * BM3D(z). """ - def __init__(self, sigma, profile="np", stage_arg=BM3DStages.ALL_STAGES, + def __init__(self, sigma, profile="np", stage_arg=ALL_STAGES, positivity=True): diff --git a/pyproject.toml b/pyproject.toml index 499858c6d8..aefeeb282a 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -81,7 +81,7 @@ gpu = [ [dependency-groups] test = [ #"ccpi-regulariser=24.0.1", # [not osx] # missing from PyPI - "cvxpy", + "cvxpy", "bm3d", "matplotlib-base>=3.3", "packaging", "scikit-image", From 954126e99321532bd03a52a8e1575d7d4552a5be Mon Sep 17 00:00:00 2001 From: epapoutsellis Date: Thu, 29 Jan 2026 15:16:03 +0000 Subject: [PATCH 05/10] add more tests --- .../optimisation/functions/BM3DFunction.py | 68 +++++++++++++++---- Wrappers/Python/test/test_functions.py | 34 ++++++++++ 2 files changed, 87 insertions(+), 15 deletions(-) diff --git a/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py b/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py index 33df666593..7d74a2699b 100644 --- a/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py +++ b/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py @@ -20,53 +20,91 @@ class BM3DFunction(Function): - """ - PnP 'regulariser' whose proximal applies BM3D denoising. - Use in PnP-ISTA/FISTA - Maybe add damping: (1-gamma) z + gamma * BM3D(z). + r""" + Plug-and-Play (PnP) BM3D prior. + + This class is meant to be used in proximal-gradient + schemes (PnP-ISTA / PnP-FISTA), where the proximal operator is replaced + by a BM3D denoiser: + \[ + \operatorname{prox}_{\tau g}(x) \approx D_\sigma(x), + \] + with ``sigma`` interpreted as the assumed noise standard deviation in the image domain. + + Notes + ----- + * The function value ``g(x)`` is not defined for PnP; therefore + ``__call__`` returns ``0.0``. + * Optionally enforces non-negativity by projecting the denoised output + onto ``\{x \ge 0\}``. + + Parameters + ---------- + sigma : float + BM3D noise standard deviation (same units as the image). Must be > 0. + + profile : str, default="np" + BM3D profile passed to ``bm3d`` (speed/quality trade-off). Available + profiles are ``('np', 'refilter', 'vn', 'vn_old', 'high', 'deb') + + stage_arg : BM3DStages or np.ndarray, default=BM3DStages.ALL_STAGES + Controls which BM3D stage(s) are executed, or provides a pilot image: + - ``BM3DStages.ALL_STAGES``: hard-thresholding + Wiener filtering. + - ``BM3DStages.HARD_THRESHOLDING``: hard-thresholding only. + - ``np.ndarray``: a pilot estimate of the noise-free image (used by BM3D). + + positivity : bool, default=True + If ``True``, clip the denoised image to be non-negative. + + Note + ---------- + Reference: Dabov, K. and Foi, A. and Katkovnik, V. and Egiazarian, K., 2007. Image Denoising by Sparse 3-D Transform-Domain Collaborative Filtering. IEEE Transactions on Image Processing. http://dx.doi.org/10.1109/TIP.2007.901238. + """ + def __init__(self, sigma, profile="np", stage_arg=ALL_STAGES, positivity=True): - - # self.gamma = float(gamma) # damping in (0,1] - # if not (0.0 < self.gamma <= 1.0): - # raise ValueError("gamma must be in (0,1].") self.sigma = sigma if self.sigma<=0: - raise ValueError("pos") + raise ValueError("Need a positive value for sigma") self.profile = profile self.stage_arg = stage_arg self.positivity = positivity + self._warned_call = False super(BM3DFunction, self).__init__(L=None) def __call__(self, x): - # does not exist, return 0 for now, add warning + if not self._warned_call: + warnings.warn( + "BM3DFunction does not define objective value; returning 0.0.", + RuntimeWarning, + stacklevel=2, + ) + self._warned_call = True return 0.0 def _denoise(self, znp: np.ndarray) -> np.ndarray: z = np.asarray(znp, dtype=np.float32) - # BM3D expects sigma as noise std (same units as the image) return bm3d(z, sigma_psd=self.sigma, profile=self.profile, stage_arg=self.stage_arg).astype(np.float32) - def proximal(self, x, tau, out=None): + def proximal(self, x, tau=1., out=None): ## TODO asarray for SIRF? z = x.array.astype(np.float32, copy=False) den_bm3d_np = self._denoise(z) - # damping/relaxation (oscillations) - # u = (1.0 - self.gamma) * z + self.gamma * d - + ## TODO maybe we need a more general constraint? if self.positivity: np.maximum(den_bm3d_np, 0.0, out=den_bm3d_np) if out is None: out = x * 0.0 out.fill(den_bm3d_np) + return out \ No newline at end of file diff --git a/Wrappers/Python/test/test_functions.py b/Wrappers/Python/test/test_functions.py index 61e65c03b9..6c4476f17e 100644 --- a/Wrappers/Python/test/test_functions.py +++ b/Wrappers/Python/test/test_functions.py @@ -35,6 +35,7 @@ WeightedL2NormSquared, MixedL11Norm, ZeroFunction, L1Sparsity, FunctionOfAbs from cil.optimisation.functions import BlockFunction +from cil.utilities import dataexample, noise import numpy import scipy.special @@ -54,6 +55,13 @@ import numba from numbers import Number +try: + from bm3d import bm3d, BM3DStages + from cil.optimisation.functions import BM3DFunction + _HAS_BM3D = True +except Exception: + _HAS_BM3D = False + initialise_tests() if has_ccpi_regularisation: @@ -66,6 +74,7 @@ from cil.optimisation.functions.MixedL21Norm import _proximal_step_numba, _proximal_step_numpy + class TestFunction(CCPiTestClass): def test_Function(self): @@ -2226,3 +2235,28 @@ def test_convex_conjugate_not_implemented(self): self.assertEqual(self.abs_function.convex_conjugate(self.data_real32), 0.) +class TestBM3D(unittest.TestCase): + + def setUp(self): + pass + + @unittest.skipUnless(_HAS_BM3D, "Optional dependency 'bm3d'.") + def test_sigma_positive(self): + with self.assertRaises(ValueError): + BM3DFunction(sigma=0.0) + with self.assertRaises(ValueError): + BM3DFunction(sigma=-1.0) + + @unittest.skipUnless(_HAS_BM3D, "Optional dependency 'bm3d'.") + def test_proximal_(self): + data = dataexample.SHAPES.get() + + G = BM3DFunction(sigma=0.1, positivity=False) + G_prox = G.proximal(data, tau=1.0) + G_denoise = G._denoise(data.array) + + np.testing.assert_array_almost_equal(G_denoise, G_prox.array, decimal=4) + + + + From 522db668355c357f060fdb42f7a456797c4ebd3f Mon Sep 17 00:00:00 2001 From: epapoutsellis Date: Thu, 29 Jan 2026 15:17:34 +0000 Subject: [PATCH 06/10] add more tests --- Wrappers/Python/cil/optimisation/functions/BM3DFunction.py | 1 + Wrappers/Python/test/test_functions.py | 1 + 2 files changed, 2 insertions(+) diff --git a/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py b/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py index 7d74a2699b..5a9dcb99b4 100644 --- a/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py +++ b/Wrappers/Python/cil/optimisation/functions/BM3DFunction.py @@ -98,6 +98,7 @@ def proximal(self, x, tau=1., out=None): ## TODO asarray for SIRF? z = x.array.astype(np.float32, copy=False) den_bm3d_np = self._denoise(z) + ## TODO maybe we need a more general constraint? if self.positivity: diff --git a/Wrappers/Python/test/test_functions.py b/Wrappers/Python/test/test_functions.py index 6c4476f17e..09534bddd8 100644 --- a/Wrappers/Python/test/test_functions.py +++ b/Wrappers/Python/test/test_functions.py @@ -2249,6 +2249,7 @@ def test_sigma_positive(self): @unittest.skipUnless(_HAS_BM3D, "Optional dependency 'bm3d'.") def test_proximal_(self): + data = dataexample.SHAPES.get() G = BM3DFunction(sigma=0.1, positivity=False) From a1510a0ee3606c9c2b6beca76f00b3733ad9419e Mon Sep 17 00:00:00 2001 From: Laura Murgatroyd <60604372+lauramurgatroyd@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:23:03 +0000 Subject: [PATCH 07/10] Add bm3d to requirements-test.yml --- scripts/requirements-test.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/requirements-test.yml b/scripts/requirements-test.yml index 496c19b3ad..ecf4796f14 100644 --- a/scripts/requirements-test.yml +++ b/scripts/requirements-test.yml @@ -45,6 +45,7 @@ dependencies: - numba - tqdm - zenodo_get >=1.6 + - bm3d - pip - pip: - unittest-parametrize From 5edbd021d9768a31b640d8a68756d051565d7538 Mon Sep 17 00:00:00 2001 From: Laura Murgatroyd <60604372+lauramurgatroyd@users.noreply.github.com> Date: Mon, 2 Feb 2026 11:23:23 +0000 Subject: [PATCH 08/10] Add bm3d to Windows requirements --- scripts/requirements-test-windows.yml | 1 + 1 file changed, 1 insertion(+) diff --git a/scripts/requirements-test-windows.yml b/scripts/requirements-test-windows.yml index 616db62ee9..43596cc69c 100644 --- a/scripts/requirements-test-windows.yml +++ b/scripts/requirements-test-windows.yml @@ -44,6 +44,7 @@ dependencies: - numba - tqdm - zenodo_get >=1.6 + - bm3d - pip - pip: - unittest-parametrize From 5badbc9d77164fd0bf822fbdda711c8c6f9ad569 Mon Sep 17 00:00:00 2001 From: Murgatroyd Date: Wed, 4 Feb 2026 11:48:58 +0000 Subject: [PATCH 09/10] Change bm3d to being installed by pip not conda --- recipe/meta.yaml | 2 +- scripts/requirements-test.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/recipe/meta.yaml b/recipe/meta.yaml index 2365fdf5a2..7ec51ed2b0 100644 --- a/recipe/meta.yaml +++ b/recipe/meta.yaml @@ -14,7 +14,6 @@ test: - pip - python-wget - cvxpy # [linux] - - bm3d # [linux] - scikit-image - tomophantom 2.0.0 # [linux] - tigre 2.6 @@ -31,6 +30,7 @@ test: commands: - pip install unittest-parametrize + - pip install bm3d - python -m unittest discover -v -s Wrappers/Python/test {% set ipp_version = '2021.12' %} diff --git a/scripts/requirements-test.yml b/scripts/requirements-test.yml index ecf4796f14..c2aa3a3d9f 100644 --- a/scripts/requirements-test.yml +++ b/scripts/requirements-test.yml @@ -45,7 +45,7 @@ dependencies: - numba - tqdm - zenodo_get >=1.6 - - bm3d - pip - pip: - unittest-parametrize + - bm3d From fdc15d828abdea3a0251528fe41c5942c5ca2e4b Mon Sep 17 00:00:00 2001 From: Murgatroyd Date: Wed, 4 Feb 2026 11:49:57 +0000 Subject: [PATCH 10/10] removed from windows file --- scripts/requirements-test-windows.yml | 1 - 1 file changed, 1 deletion(-) diff --git a/scripts/requirements-test-windows.yml b/scripts/requirements-test-windows.yml index 43596cc69c..616db62ee9 100644 --- a/scripts/requirements-test-windows.yml +++ b/scripts/requirements-test-windows.yml @@ -44,7 +44,6 @@ dependencies: - numba - tqdm - zenodo_get >=1.6 - - bm3d - pip - pip: - unittest-parametrize