Source code for phenotypic.enhance._structure_smoothing

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from phenotypic._core._image import Image

import numpy as np
from pydantic import ConfigDict, model_validator
from scipy.ndimage import gaussian_filter
from skimage.feature import structure_tensor_eigenvalues
from typing import Annotated
from typing_extensions import Self

from ..abc_ import Smoothing
from ..sdk_.typing_ import TuneSpec


[docs] class StructureSmoothing(Smoothing): """Enhance filamentous structures in ``detect_mat`` via coherence-enhancing anisotropic diffusion. Iteratively smooths the image preferentially along locally coherent orientations while suppressing diffusion across boundaries. A two-scale structure tensor (noise scale ``sigma``, integration scale ``rho``) estimates local orientation at each step, and the diffusion tensor is oriented to follow elongated features such as fungal hyphae, streak inoculations, and branching colony morphologies. For algorithm details, see :doc:`/explanation/what_enhancement_does`. Best For: - Filamentous fungal hyphae (Aspergillus, Penicillium, molds) where branching mycelial networks need reinforcement before ridge detection. - Streak inoculation patterns where colonies grow along lines. - Preprocessing before :class:`FocusEdgeSato`, :class:`FocusEdgeFrangi`, or :class:`FocusEdgeMeijering` to reduce noise without erasing tubular structures. - Faint elongated features in low-contrast or noisy plate scans. Consider Also: - :class:`LocalEdgeDenoise` for isotropic edge-preserving denoising of round colonies where directional enhancement is not needed. - :class:`FocusEdgeSato` for direct ridge detection on images that are already clean enough to skip a diffusion preprocessing step. - :class:`GaussianBlur` when isotropic smoothing is sufficient and directional selectivity is not required. Args: num_iter: Number of diffusion iterations. Each iteration advances the PDE one time step of size ``dt``; the total diffusion extent is proportional to ``num_iter * dt``. Typical range: 5--100; values 15--30 are the practical working range for most plate-scan images. Default: 20. sigma: Noise scale for Gaussian derivative computation in pixels. Sets the spatial frequency band at which local orientation is estimated. Match to the half-width of structures to enhance: fine hyphae (< 3 px) need sigma 0.5--1.0; thick ridges or streak bands tolerate 2--4. Typical range: 0.5--5.0. Default: 1.5. rho: Integration scale for structure tensor smoothing in pixels. Averaging the outer-product tensor over ``rho`` produces a smoother orientation field that follows gently curving structures. Must be >= ``sigma``. ``None`` (default) sets ``rho = sigma`` (single-scale mode). Typical values: 2--3x ``sigma``; use values close to ``sigma`` for tight-turning branching mycelium and larger values for long straight streaks. dt: Time step per diffusion iteration. Must satisfy the 2D forward-Euler stability bound (dt <= 0.125). The extent of smoothing is governed by the total diffusion time T = ``num_iter * dt``, so prefer adjusting ``num_iter`` rather than ``dt`` when tuning effect magnitude. Typical range: 0.05--0.125. Default: 0.1. alpha: Minimum diffusivity coefficient (0 < alpha < 1). Prevents the perpendicular-to-structure diffusivity from collapsing to zero and maximises anisotropy at small values (0.001); larger values (0.01--0.1) add isotropic regularisation. Default: 0.001. C: Percentile of the initial coherence histogram used as the adaptive contrast threshold (0 < C <= 100). High values (95--99) restrict anisotropic diffusion to only the most coherent pixels; lower values (70--90) spread it to weaker elongated structures. Default: 99. Returns: Image: Input image with ``detect_mat`` smoothed along coherent structures. ``rgb`` and ``gray`` are unchanged. Raises: ValueError: If ``num_iter`` < 1, ``dt`` is not in (0, 0.125], ``sigma`` <= 0, ``rho`` < ``sigma``, ``alpha`` not in (0, 1), or ``C`` not in (0, 100]. References: [1] J. Weickert, "Coherence-enhancing diffusion filtering," *Int. J. Comput. Vis.*, vol. 31, no. 2/3, pp. 111--127, Apr. 1999. See Also: :doc:`/tutorials/notebooks/03_enhancing_before_detection` for a visual walkthrough of enhancement pipelines on plate images. :doc:`/tutorials/notebooks/10_detecting_filamentous_fungi` for pipelines that use this enhancer before ridge detection. :doc:`/explanation/what_enhancement_does` for background on anisotropic diffusion and the structure tensor. """ model_config = ConfigDict(populate_by_name=True) num_iter: Annotated[int, TuneSpec(15, 30)] = 20 sigma: Annotated[float, TuneSpec(0.5, 5.0, log=True)] = 1.5 # rho left tunable=False: it must satisfy the cross-field constraint # rho >= sigma (enforced in _check_diffusion_params); independent # sampling against a co-tuned sigma would generate invalid combos. rho: Annotated[float | None, TuneSpec(tunable=False)] = None dt: Annotated[float, TuneSpec(0.05, 0.125)] = 0.1 alpha: float = 0.001 C: float = 99.0 @model_validator(mode="after") def _check_diffusion_params(self) -> Self: """Reproduce the pre-migration ``__init__`` parameter guards. Several checks (notably ``rho >= sigma``) span more than one field, so all guards live in a single model validator rather than per-field validators. Raises: ValueError: If any diffusion parameter is outside its valid range (see the class ``Args:`` block). """ if self.num_iter < 1: raise ValueError("num_iter must be >= 1") if self.dt <= 0: raise ValueError("dt must be > 0") if self.dt > 0.125: raise ValueError( "dt > 0.125 exceeds the 2D forward-Euler stability bound (1/8); " "use smaller values" ) if self.sigma <= 0: raise ValueError("sigma must be > 0") if self.rho is not None: if self.rho <= 0: raise ValueError("rho must be > 0") if self.rho < self.sigma: raise ValueError( f"rho ({self.rho}) must be >= sigma ({self.sigma}); the " "integration scale cannot be smaller than the noise scale" ) if not (0 < self.alpha < 1): raise ValueError("alpha must be in (0, 1)") if not (0 < self.C <= 100): raise ValueError("C must be in (0, 100]") return self @staticmethod def _central_diff(arr: np.ndarray, axis: int) -> np.ndarray: """First derivative via acc=2 central stencil (matches FinDiff(axis, 1.0, 1)). Interior points use second-order central differences ``[-0.5, 0, 0.5]``. Boundary points use second-order one-sided stencils identical to ``findiff.FinDiff`` with default ``acc=2``. Args: arr: Input array (2D or higher). axis: Axis along which to differentiate. Returns: Array of same shape with first derivative along *axis*. """ out = np.empty_like(arr) n = arr.shape[axis] s = [slice(None)] * arr.ndim def sl(start: int | None, stop: int | None) -> tuple: s[axis] = slice(start, stop) return tuple(s) def ix(i: int) -> tuple: s[axis] = i return tuple(s) # Interior: second-order central [-0.5, 0, 0.5] out[sl(1, n - 1)] = 0.5 * (arr[sl(2, n)] - arr[sl(0, n - 2)]) # Forward boundary (acc=2): [-1.5, 2.0, -0.5] out[ix(0)] = -1.5 * arr[ix(0)] + 2.0 * arr[ix(1)] - 0.5 * arr[ix(2)] # Backward boundary (acc=2): [0.5, -2.0, 1.5] out[ix(n - 1)] = ( 0.5 * arr[ix(n - 3)] - 2.0 * arr[ix(n - 2)] + 1.5 * arr[ix(n - 1)] ) return out def _operate(self, image: Image) -> Image: """Apply coherence-enhancing diffusion to enhance filamentous structures.""" # Work with float64 for numerical stability img = image.detect_mat[:].astype(np.float64) # Resolve integration scale (rho defaults to sigma for single-scale mode) rho = self.rho if self.rho is not None else self.sigma # Compute contrast threshold from the original image's coherence # histogram (Cth percentile), so it adapts to image content u_r0 = gaussian_filter(img, sigma=self.sigma, order=[1, 0]) u_c0 = gaussian_filter(img, sigma=self.sigma, order=[0, 1]) S_rr0 = gaussian_filter(u_r0 * u_r0, sigma=rho) S_rc0 = gaussian_filter(u_r0 * u_c0, sigma=rho) S_cc0 = gaussian_filter(u_c0 * u_c0, sigma=rho) l1_0, l2_0 = structure_tensor_eigenvalues( [S_rr0, S_rc0, S_cc0], ) contrast_threshold = np.percentile( (l1_0 - l2_0) ** 2, self.C, ) for _ in range(self.num_iter): # Two-scale structure tensor (Weickert IJCV 1999) # Gaussian derivatives at noise scale sigma u_r = gaussian_filter(img, sigma=self.sigma, order=[1, 0]) u_c = gaussian_filter(img, sigma=self.sigma, order=[0, 1]) # Outer product, integrated at scale rho S_rr = gaussian_filter(u_r * u_r, sigma=rho) S_rc = gaussian_filter(u_r * u_c, sigma=rho) S_cc = gaussian_filter(u_c * u_c, sigma=rho) lambda1, lambda2 = structure_tensor_eigenvalues( [S_rr, S_rc, S_cc], ) # Coherence measure (unnormalized, per Weickert IJCV 1999) coherence = (lambda1 - lambda2) ** 2 # Diffusion coefficients based on coherence # c1: diffusion perpendicular to structure (small, preserves edges) # c2: diffusion along structure (large where coherent) c1 = self.alpha c2 = (self.alpha + (1 - self.alpha) * np.exp(-contrast_threshold / (coherence + 1e-10) )) # Local orientation from structure tensor theta = 0.5 * np.arctan2(2 * S_rc, S_rr - S_cc) # Diffusion tensor components in (row, col) coordinates # Cache trig to avoid redundant transcendental calls c = np.cos(theta) s = np.sin(theta) cos2 = c * c sin2 = s * s cossin = c * s D_rr = c1 * cos2 + c2 * sin2 D_rc = (c1 - c2) * cossin D_cc = c1 * sin2 + c2 * cos2 # Compute gradients using acc=2 central finite differences gx = self._central_diff(img, 1) # du/dcol gy = self._central_diff(img, 0) # du/drow # Flux: pair D_cc with du/dcol, D_rr with du/drow Fx = D_cc * gx + D_rc * gy Fy = D_rc * gx + D_rr * gy # Flux divergence div = self._central_diff(Fx, 1) + self._central_diff(Fy, 0) # Update image with diffusion step img = img + self.dt * div # Store result back to detection matrix, clipping to valid range image.detect_mat[:] = (np.clip(img, 0.0, 1.0) .astype(image.detect_mat.dtype)) return image