Source code for phenotypic.correction._stable_denoise

from __future__ import annotations

from typing import TYPE_CHECKING, Literal

import bm3d
import numpy as np
from bm3d.profiles import BM3DStages

if TYPE_CHECKING:
    from phenotypic._core._image import Image

from ..abc_ import ImageCorrector
from ..enhance._anscombe_forward import AnscombeForward
from ..enhance._anscombe_inverse import AnscombeInverse


[docs] class StableDenoise(ImageCorrector): """Denoise grayscale channels using variance-stabilized BM3D collaborative filtering. Combine the Generalized Anscombe Transform (GAT) with BM3D denoising in a single corrector step. The GAT stabilizes Poisson-Gaussian noise variance so that BM3D operates optimally, then the inverse GAT restores the original intensity scale. Writing through the gray accessor triggers a detect_mat reset, so downstream reads reflect the denoised result. For algorithm details, see :doc:`/explanation/what_enhancement_does`. Args: block_size: BM3D patch side length in pixels. Larger values capture more context but increase computation. Default: ``8``. stage_arg: Processing stages. ``'all_stages'`` runs hard thresholding followed by Wiener filtering for best quality; ``'hard_thresholding'`` is faster. Default: ``'all_stages'``. gain: Camera gain in electrons per ADU. Default: ``1.0``. mu: Read-noise mean (baseline offset). Default: ``0.0``. sigma: Read-noise standard deviation. ``0.0`` assumes pure Poisson noise, appropriate for most plate scanners. Default: ``0.0``. scale_factor: Multiplier converting normalized [0, 1] data to photon counts. ``None`` auto-detects from image bit depth. Default: ``None``. Returns: Image: Input image with grayscale channel denoised via the accessor cascade. RGB is unchanged. Raises: ValueError: If ``gain`` is not positive, ``sigma`` is negative, ``scale_factor`` is not positive, or ``stage_arg`` is not a recognized value. Best For: - Low-light or high-ISO plate images with photon-counting (Poisson-Gaussian) noise. - Improving intensity measurement accuracy before colony size or opacity quantification. - CCD/CMOS scanned plates where mixed noise models apply. Consider Also: - :class:`BayesShrinkCorrector` when all components (including RGB) need denoising simultaneously. - :class:`BM3DDenoiser` for enhancer-only BM3D on the detection matrix without modifying grayscale. - :class:`VisuShrinkCorrector` for a faster wavelet-based alternative when Poisson noise modelling is not required. References: [1] M. Makitalo and A. Foi, "Optimal inversion of the generalized Anscombe transformation for Poisson-Gaussian noise," *IEEE Trans. Image Process.*, vol. 22, no. 1, pp. 91--103, Jan. 2013. See Also: :doc:`/how_to/notebooks/correct_color_cast` for combining denoising with color correction workflows. """
[docs] def __init__( self, block_size: int = 8, stage_arg: Literal["all_stages", "hard_thresholding"] = "all_stages", *, gain: float = 1.0, mu: float = 0.0, sigma: float = 0.0, scale_factor: float | None = None, ): """Initialize GAT-stabilized BM3D corrector for gray and detect_mat. Args: block_size (int): BM3D patch size. Default 8. stage_arg (Literal["all_stages", "hard_thresholding"]): Denoising stages. 'all_stages' gives best quality; 'hard_thresholding' is faster. gain (float): Camera gain in electrons per ADU. Default 1.0. mu (float): Read noise mean (baseline offset). Default 0.0. sigma (float): Read noise standard deviation. Default 0.0 (pure Poisson noise). scale_factor (float | None): Converts normalized [0,1] data to counts. None (default) auto-detects from image metadata. """ if gain <= 0: raise ValueError(f"gain must be > 0, got {gain}") if sigma < 0: raise ValueError(f"sigma must be >= 0, got {sigma}") if scale_factor is not None and scale_factor <= 0: raise ValueError(f"scale_factor must be > 0, got {scale_factor}") if stage_arg not in ("all_stages", "hard_thresholding"): raise ValueError( f"stage_arg must be 'all_stages' or 'hard_thresholding', " f"got {stage_arg!r}" ) self.block_size = block_size self.stage_arg = stage_arg self.gain = float(gain) self.mu = float(mu) self.sigma = float(sigma) self.scale_factor = ( float(scale_factor) if scale_factor is not None else None )
def _get_scale_factor(self, image: Image) -> float: """Get scale factor, auto-detecting from image metadata. Args: image: The Image to get scale factor for. Returns: Scale factor for converting normalized [0,1] data to counts. """ if self.scale_factor is not None: return self.scale_factor bit_depth = getattr(image.metadata, "bit_depth", None) if bit_depth == 8: return 255.0 elif bit_depth == 16: return 65535.0 else: return 255.0 def _denoise_channel( self, channel: np.ndarray, scale_factor: float ) -> np.ndarray: """Denoise a single [0,1] channel via GAT -> BM3D -> inverse GAT. Args: channel: 2D array in [0, 1] range. scale_factor: Multiplier to convert [0,1] to counts. Returns: Denoised 2D array clipped to [0, 1]. """ # [0,1] -> counts counts = channel * scale_factor # Forward GAT: stabilize Poisson-Gaussian variance stabilized = AnscombeForward._generalized_anscombe( counts, self.mu, self.sigma, self.gain ) # BM3D denoise in GAT domain (sigma_psd=1.0 is theoretically correct) profile = bm3d.BM3DProfile() profile.bs_ht = self.block_size profile.bs_wiener = self.block_size denoised = bm3d.bm3d( stabilized, profile=profile, sigma_psd=1.0, stage_arg=self._convert_stage_arg(self.stage_arg), ) # Inverse GAT: recover counts recovered = AnscombeInverse._inverse_generalized_anscombe( denoised, self.mu, self.sigma, self.gain ) # counts -> [0,1], clip return (recovered / scale_factor).clip(0.0, 1.0) def _operate(self, image: Image) -> Image: """Apply GAT-stabilized BM3D denoising to grayscale channel. Writes denoised result via ``image.gray[:]`` accessor, which triggers ``detect_mat.reset()`` so downstream detect_mat reads reflect the denoised grayscale. Returns: Modified Image with gray denoised via accessor cascade. RGB unchanged. """ scale_factor = self._get_scale_factor(image) image.gray[:] = self._denoise_channel(image._data.gray, scale_factor) return image @staticmethod def _convert_stage_arg( stage_arg: Literal["all_stages", "hard_thresholding"], ) -> BM3DStages: """Convert string stage argument to BM3DStages enum.""" match stage_arg: case "hard_thresholding": return BM3DStages.HARD_THRESHOLDING case "all_stages": return BM3DStages.ALL_STAGES case _: raise ValueError(f"Unknown stage arg: {stage_arg}")