Source code for phenotypic.enhance._enhance_block_match

from __future__ import annotations

from typing import TYPE_CHECKING, Annotated, ClassVar, Literal

if TYPE_CHECKING:
    from phenotypic._core._image import Image
import bm3d
from bm3d.profiles import BM3DStages
from pydantic import Field

from ..abc_ import ImageDenoiser
from ..sdk_.mixin import _GATSupportMixin
from ..sdk_.typing_ import TuneSpec


[docs] class EnhanceBlockMatch(_GATSupportMixin, ImageDenoiser): """Denoise ``detect_mat`` using block-matching and 3-D collaborative filtering. Groups similar image patches across the plate and filters them jointly in a 3-D transform domain, removing scanner banding, CCD read patterns, and imaging-hardware texture while retaining colony edges and fine morphological features. The optional second Wiener-filtering stage further sharpens colony boundaries relative to the initial hard-thresholding pass. For algorithm details see :doc:`/explanation/what_enhancement_does`. Best For: - Structured patterned noise from scanner CCD rows or camera sensor banding visible across the plate background. - Low-light incubator or plate-reader images where high ISO introduces spatially correlated noise. - Plates where fine colony morphology — wrinkle texture, satellite colonies, rough biofilm edges — must survive denoising. - Pipelines that already use ``'all_stages'`` for highest-quality deliverables and can absorb the additional compute. - Poisson-Gaussian mixed noise (fluorescence readers, sCMOS cameras) via ``use_gat=True``. Consider Also: - :class:`BayesShrinkEnhancer` for per-subband adaptive wavelet denoising with lower computational cost when structured patterns are not the primary concern. - :class:`NonLocalMeansDenoiser` for patch-based denoising at moderately lower computational overhead. - :class:`VisuShrinkEnhancer` when speed matters more than quality and a universal wavelet threshold is acceptable. - :class:`MedianFilter` for isolated salt-and-pepper impulse noise rather than Gaussian or structured noise. Args: sigma_psd: Noise standard deviation on the [0, 1] normalised intensity scale. Acts as the noise-model oracle for both the hard-thresholding and Wiener stages. Typical range: 0.01--0.05 for moderate scanner noise; 0.05--0.15 for heavy noise. Setting too low leaves structured patterns intact; too high smooths away colony texture alongside noise. Ignored when ``use_gat=True`` — the stabilised-domain value 1.0 is used internally. Default: 0.02. block_size: Side length (pixels) of square patches used for block-matching. Larger blocks capture more self-similar structure and increase denoising strength at the cost of speed and potential over-smoothing of fine colony detail. Typical range: 4--16; integer powers of 2 are conventional. Default: 8. stage_arg: Processing pipeline. ``'all_stages'`` (default) runs the hard-thresholding stage followed by a Wiener-filtering stage using the HT estimate as an oracle, producing better boundary sharpness. ``'hard_thresholding'`` runs only the first stage — approximately 2× faster with slightly coarser colony edges. Accepted values: ``'all_stages'``, ``'hard_thresholding'``. Default: ``'all_stages'``. clip: Clamp output to [0, 1] after BM3D aggregation. BM3D can produce values marginally outside [0, 1] due to weighted patch accumulation. Default: ``True``. Automatically set to ``False`` inside the GAT region when ``use_gat=True``. # GAT parameters — only active when use_gat=True use_gat: Wrap the BM3D call in a forward Generalised Anscombe Transform (GAT) → denoise at fixed sigma_psd=1.0 → exact unbiased inverse GAT pipeline. Enables correct denoising under mixed Poisson-Gaussian noise from low-light sensors. Leave ``False`` for standard flatbed scanner images where additive Gaussian read noise dominates. Default: ``False``. gat_gain: Camera gain in electrons per ADU, used by the GAT to model Poisson variance scaling. Obtain from the sensor datasheet or a photon-transfer curve. Only relevant when ``use_gat=True``. Default: 1.0. gat_mu: Read-noise mean (DC baseline offset) in ADU before [0, 1] normalisation. Set to the dark-current bias level if the image has not been background-subtracted. Only relevant when ``use_gat=True``. Default: 0.0. gat_read_sigma: Standard deviation of the Gaussian read-noise component (electrons RMS). Setting this to the manufacturer's read noise improves stabilisation accuracy under mixed Poisson-Gaussian conditions. Only relevant when ``use_gat=True``. Default: 0.0. gat_scale_factor: Multiplier converting the [0, 1] normalised ``detect_mat`` back to photon counts before the forward GAT. ``None`` auto-detects from ``image.metadata.bit_depth`` (255 for 8-bit, 65535 for 16-bit). Supply an explicit value when the ``detect_mat`` was normalised by a range other than the bit-depth maximum. Only relevant when ``use_gat=True``. Default: ``None``. Returns: Image: Input image with ``detect_mat`` denoised via BM3D collaborative filtering. ``rgb`` and ``gray`` are unchanged. References: [1] K. Dabov, A. Foi, V. Katkovnik, and K. Egiazarian, "Image denoising by sparse 3-D transform-domain collaborative filtering," *IEEE Trans. Image Process.*, vol. 16, no. 8, pp. 2080--2095, Aug. 2007. [2] M. Mäkitalo 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:`/tutorials/notebooks/03_enhancing_before_detection` for a visual walkthrough of denoising pipelines on plate images. :doc:`/how_to/notebooks/denoise_low_light` for BM3D and other denoising strategies on low-light plate images. :doc:`/explanation/what_enhancement_does` for background on block-matching and collaborative filtering. """ _GAT_NOISE_PARAMS: ClassVar[dict[str, float]] = { "use_gat" : True, "sigma_psd": 1.0 } _GAT_DEFER_ATTRS: ClassVar[tuple[str, ...]] = ("clip",) sigma_psd: Annotated[float, TuneSpec(0.01, 0.15, log=True)] = Field(0.02, ge=0.0) block_size: Annotated[int, TuneSpec(tunable=False)] = 8 stage_arg: Literal["all_stages", "hard_thresholding"] = "all_stages" clip: bool = True def _operate(self, image: Image) -> Image: # detect_mat is guaranteed to be in [0, 1] range, which BM3D expects # (or in stabilized counts when use_gat=True; the mixin handles the # forward/inverse round-trip and noise-param retargeting). self._gat_apply(image, "detect_mat", self._denoise_detect_mat) return image def _denoise_detect_mat(self, image: Image) -> None: profile = bm3d.BM3DProfile() profile.bs_ht = self.block_size profile.bs_wiener = self.block_size denoised = bm3d.bm3d( image.detect_mat[:], profile=profile, sigma_psd=self.sigma_psd, stage_arg=self._convert_stage_arg(self.stage_arg), ) if self.clip: denoised = denoised.clip(0.0, 1.0) image.detect_mat[:] = denoised def _convert_stage_arg( self, stage_arg: Literal["all_stages", "hard_thresholding"] ): 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}")