Source code for phenotypic.enhance._coherence_enhancing_diffusion

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from phenotypic._core._image import Image

import numpy as np
from scipy.ndimage import gaussian_filter
from skimage.feature import structure_tensor_eigenvalues

from ..abc_ import ImageEnhancer


[docs] class CoherenceEnhancingDiffusion(ImageEnhancer): """Enhance filamentous structures via anisotropic coherence-enhancing diffusion. Smooths ``detect_mat`` preferentially along coherent structures (lines, ridges, edges) while preserving boundaries perpendicular to them. Uses the structure tensor to estimate local orientation and applies directional diffusion that follows elongated features such as fungal hyphae, streak inoculations, and branching colony morphologies. For algorithm details, see :doc:`/explanation/what_enhancement_does`. Args: num_iter: Number of diffusion iterations. Typical range: 5--100. Small values (5--10) give subtle enhancement; medium values (15--30) are typical; large values (50--100) provide heavy smoothing. Default: 20. sigma: Noise/derivative scale for Gaussian gradient computation. Match to the width of structures to enhance. Typical range: 0.5--5.0. Default: 1.5. rho: Integration scale for structure tensor smoothing. Must be >= ``sigma``. ``None`` (default) uses ``sigma`` (single-scale mode). Typical values: 2--3x ``sigma``. dt: Time step per iteration. Must satisfy the 2D forward-Euler stability bound (<=0.125). Typical range: 0.05--0.125. Default: 0.1. alpha: Minimum diffusivity (0 < alpha < 1). Small values (0.001) maximize anisotropy; larger values (0.01--0.1) add isotropic smoothing. Default: 0.001. C: Contrast percentile (0 < C <= 100) for the adaptive coherence threshold. Higher values restrict anisotropy to the most coherent structures. Default: 99. Returns: Image: Input image with ``detect_mat`` smoothed along coherent structures. ``rgb`` and ``gray`` are unchanged. Best For: - Filamentous fungal hyphae (Aspergillus, Penicillium, molds) where branching structures need enhancement. - Streak inoculation patterns where colonies grow along lines. - Preprocessing before ridge detection (Frangi, Sato, Meijering) to reduce noise without losing tubular structures. - Faint elongated features in low-contrast or noisy scans. Consider Also: - :class:`BilateralDenoise` for isotropic edge-preserving denoising of round colonies without directional features. - :class:`SatoRidgeFilter` for direct ridge detection without a diffusion preprocessing step. - :class:`MeijeringRidgeFilter` for detecting very fine neurite-like filaments. 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:`/explanation/what_enhancement_does` for background on anisotropic diffusion and structure tensor analysis. """
[docs] def __init__( self, num_iter: int = 20, sigma: float = 1.5, rho: float | None = None, dt: float = 0.1, *, alpha: float = 0.001, C: float = 99.0, ): """ Parameters: num_iter (int): Number of diffusion iterations. Controls the total amount of smoothing applied. Small values (5-10) give subtle enhancement; medium values (15-30) are typical; large values (50-100) provide heavy smoothing. Computational cost scales linearly with iterations. Recommended: 20 for balanced enhancement. sigma (float): Noise/derivative scale (Gaussian derivative σ). Controls the scale at which image gradients are computed for orientation estimation. Match to the width of structures you want to enhance: ~1.5 for fine hyphae (~3px wide), ~3.0 for coarser structures. Recommended: 1.5. rho (float | None): Integration scale for structure tensor smoothing. Controls the neighborhood over which gradient products are averaged. Must be >= sigma. When None (default), equals sigma (single-scale mode). Larger values produce smoother orientation fields. Typical: 2-3x sigma. dt (float): Time step for each diffusion iteration. Must satisfy the 2D forward-Euler stability bound of 1/8 (0.125). Smaller values require more iterations for equivalent smoothing. Recommended: 0.1 for stable, efficient diffusion. alpha (float): Minimum diffusivity parameter (0 < alpha < 1). Ensures some diffusion even in uniform regions, preventing numerical issues. Small values (0.001) maximize anisotropy; larger values (0.01-0.1) add more isotropic smoothing. Recommended: 0.001 for strong directional bias. C (float): Contrast percentile for the diffusivity function (0 < C <= 100). The Cth percentile of the coherence histogram (lambda1 - lambda2)^2 from the original image is used as the contrast threshold, adapting to image content. Higher values restrict anisotropy to the most coherent structures. Default: 99. """ if num_iter < 1: raise ValueError("num_iter must be >= 1") if dt <= 0: raise ValueError("dt must be > 0") if dt > 0.125: raise ValueError( "dt > 0.125 exceeds the 2D forward-Euler stability bound (1/8); " "use smaller values" ) if sigma <= 0: raise ValueError("sigma must be > 0") if rho is not None: if rho <= 0: raise ValueError("rho must be > 0") if rho < sigma: raise ValueError( f"rho ({rho}) must be >= sigma ({sigma}); the integration " "scale cannot be smaller than the noise scale" ) if not (0 < alpha < 1): raise ValueError("alpha must be in (0, 1)") if not (0 < C <= 100): raise ValueError("C must be in (0, 100]") self.num_iterations = int(num_iter) self.dt = float(dt) self.sigma = float(sigma) self.rho = float(rho) if rho is not None else None self.alpha = float(alpha) self.C = float(C)
@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_iterations): # 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