Source code for phenotypic.enhance._focus_edge_phase

"""Phase congruency enhancement for contrast-invariant edge detection.

Implementation follows Kovesi's phasecong3 algorithm using oriented log-Gabor wavelets.
Algorithm details from ImagePhaseCongruency.jl (Julia reference implementation).

References:
    Peter Kovesi's ImagePhaseCongruency.jl: https://github.com/peterkovesi/ImagePhaseCongruency.jl
"""

from __future__ import annotations

from dataclasses import dataclass
from typing import TYPE_CHECKING, Annotated, List, Literal

import numpy as np
from numpy.fft import fft2, ifft2, ifftshift
from pydantic import Field

from ..abc_ import FocusEdge
from ..sdk_.typing_ import TuneSpec

if TYPE_CHECKING:
    from phenotypic._core._image import Image


@dataclass
class _PhaseCong3Result:
    """Internal container for phasecong3 results.

    Attributes:
        M: Maximum moment of phase congruency covariance (edge strength).
        m: Minimum moment of phase congruency covariance (corner strength).
        orientation: Feature orientation in radians [-pi/2, pi/2]. 0 corresponds
            to a vertical edge, pi/2 is horizontal. Positive is anticlockwise.
        feature_type: Local weighted mean phase angle. pi/2 corresponds to a
            bright line, 0 to a step edge, -pi/2 to a dark line.
        T: Calculated noise threshold.
        pc_sum: Mean phase congruency across all orientations (normalized).
    """

    M: np.ndarray
    m: np.ndarray
    orientation: np.ndarray
    feature_type: np.ndarray
    T: float
    pc_sum: np.ndarray


[docs] class FocusEdgePhase(FocusEdge): """Enhance colony edges in ``detect_mat`` using contrast-invariant phase congruency. Detects features where log-Gabor Fourier components are maximally in phase, producing an edge response that depends on phase agreement rather than amplitude. The result is invariant to local illumination level and scanner vignetting, making faint or translucent colony boundaries visible even where intensity-gradient methods fail. For algorithm details see :doc:`/explanation/what_enhancement_does`. Best For: - Colony boundaries that vary in opacity or contrast across the plate due to pigmentation differences, agar depth variation, or colony age. - Plates with scanner vignetting or uneven illumination where gradient-based filters produce inconsistent edge strength. - Faint, translucent colonies on bright agar where the amplitude signal is weak but phase coherence is preserved. - Filamentous fungi plates where edges span a wide range of orientations and ``n_orient=8`` captures all hyphal angles. Consider Also: - :class:`FocusEdgeFrangi` for elongated hyphae when vesselness selectivity for ridge shape is more important than illumination invariance. - :class:`FocusEdgeHessian` for multi-scale ridge and edge detection with explicit blob-sensitivity control. - :class:`SharpenEdgeGauss` for edge sharpening that preserves the original intensity profile on uniformly illuminated plates. Args: n_scale: Number of log-Gabor octave scales. More scales integrate phase evidence over a wider spatial frequency range, giving a smoother but spatially broader response. Typical range: 3--6. Default: 4. n_orient: Number of oriented filter lobes. 6 gives 30-degree angular spacing (suitable for circular yeast colony edges); 8 gives 22.5-degree spacing and better sensitivity to hyphae at arbitrary angles. Typical range: 4--8. Default: 6. min_wavelength: Center wavelength (pixels) of the finest log-Gabor scale. Should be matched to the narrowest expected colony edge width; must be >= 2 (Nyquist limit enforced by validator). Smaller values detect finer high-frequency features; larger values focus on broader edges. Default: 3.0. mult: Ratio between successive scale wavelengths. Together with ``sigma_onf`` it determines inter-scale spectral coverage. ``2.1`` is the upstream default. For even coverage of the spectrum Kovesi recommends pairing ``mult`` and ``sigma_onf`` together; e.g. ``sigma_onf=0.55`` / ``mult=3`` gives roughly 2-octave bandwidth and ``sigma_onf=0.75`` / ``mult=1.6`` gives roughly 1-octave bandwidth. Re-tune both whenever either changes. Must be > 1. Default: 2.1. sigma_onf: Log-Gabor bandwidth ratio (standard deviation of the Gaussian transfer function divided by the filter center frequency). Smaller values give wider bandwidth (more octaves per scale, broadband, suited for plates with a wide range of colony sizes); larger values give narrower, more frequency-selective bandwidth. For even spectral coverage pair with ``mult`` per the upstream table (``0.55`` with ``mult=3``, ``0.75`` with ``mult=1.6``). Valid range: 0.1--1.0. Default: 0.55. k: Noise threshold multiplier in units of the estimated Rayleigh noise standard deviation. Higher values (5--20) suppress more noise at the cost of missing faint colony edges; lower values (1--3) maximise edge recall on clean images. Value 0 disables noise thresholding entirely. Default: 2.0. cutoff: Frequency spread penalty threshold. Phase congruency values are penalised via a sigmoid when the multi-scale amplitude spread falls below this fraction, discouraging single-scale responses. Valid range: (0, 1) exclusive. Default: 0.5. g: Sigmoid sharpness controlling the transition from penalised to unpenalised frequency spread. Higher values create a near-binary gate; lower values create a gradual blend. Must be > 0. Default: 10.0. noise_method: Noise threshold estimation strategy. ``-1.0`` (default) estimates from the median of the smallest-scale filter amplitude (robust, recommended for heterogeneous plate populations). ``-2.0`` uses the Rayleigh histogram mode (more robust on images with strong background gradients). Any value >= 0 bypasses estimation and uses that value as a fixed threshold, enabling fully deterministic pipelines. Default: -1.0. output: Which phase congruency quantity to store in ``detect_mat``. ``'pc_sum'`` (default) is the mean phase congruency across all orientations, normalised to [0, 1]; best general-purpose edge map for downstream thresholding. ``'M'`` is the maximum eigenvalue of the phase congruency covariance tensor (edge strength along continuous curves). ``'m'`` is the minimum eigenvalue (corner and junction strength). Accepted values: ``'pc_sum'``, ``'M'``, ``'m'``. Default: ``'pc_sum'``. Returns: Image: Input image with ``detect_mat`` replaced by the phase congruency map, clipped to [0, 1]. ``rgb`` and ``gray`` are unchanged. Raises: ValueError: If ``n_scale`` < 1, ``n_orient`` < 1, ``min_wavelength`` < 2, ``mult`` <= 1, ``sigma_onf`` outside [0.1, 1.0], ``k`` < 0, ``cutoff`` outside (0, 1), or ``g`` <= 0. References: [1] P. Morrone and R. A. Owens, "Feature detection from local energy," *Pattern Recognit. Lett.*, vol. 6, no. 5, pp. 303--313, Dec. 1987. [2] M. C. Morrone and D. C. Burr, "Feature detection in human vision: A phase-dependent energy model," *Proc. R. Soc. London, Ser. B*, vol. 235, no. 1280, pp. 221--245, Dec. 1988. [3] P. Kovesi, "Phase congruency: A low-level image invariant," *Psychol. Res.*, vol. 64, no. 2, pp. 136--148, Aug. 2000. [4] D. J. Field, "Relations between the statistics of natural images and the response properties of cortical cells," *J. Opt. Soc. Am. A*, vol. 4, no. 12, pp. 2379--2394, Dec. 1987. See Also: :doc:`/tutorials/notebooks/03_enhancing_before_detection` for a visual walkthrough of contrast-invariant enhancement on plate images. :doc:`/explanation/what_enhancement_does` for background on phase congruency and the Local Energy Model. """ n_scale: Annotated[int, TuneSpec(3, 6)] = Field(4, ge=1) n_orient: Annotated[int, TuneSpec(4, 8)] = Field(6, ge=1) min_wavelength: Annotated[float, TuneSpec(2.0, 10.0)] = Field(3.0, ge=2.0) mult: Annotated[float, TuneSpec(1.5, 3.0)] = Field(2.1, gt=1.0) sigma_onf: Annotated[float, TuneSpec(0.1, 1.0)] = Field(0.55, ge=0.1, le=1.0) # Lower bound 0.5 (not 0.0): k=0 disables noise thresholding, a degenerate # search anchor that the optimizer should never spend trials on. k: Annotated[float, TuneSpec(0.5, 20.0)] = Field(2.0, ge=0.0) cutoff: Annotated[float, TuneSpec(0.3, 0.7)] = Field(0.5, gt=0.0, lt=1.0) g: Annotated[float, TuneSpec(2.0, 20.0)] = Field(10.0, gt=0.0) noise_method: Annotated[float, TuneSpec(tunable=False)] = -1.0 output: Literal["M", "m", "pc_sum"] = "pc_sum" def _operate(self, image: Image) -> Image: """Apply phase congruency enhancement to the detection matrix channel.""" result = self._phasecong3(image.detect_mat[:]) # Select output based on configuration output_map = {"M": result.M, "m": result.m, "pc_sum": result.pc_sum} selected = output_map[self.output] # Ensure output is in [0, 1] range for detect_mat compatibility. # detect_mat enforces float32 on assignment, so no explicit cast is needed. image.detect_mat[:] = np.clip(selected, 0.0, 1.0) return image def _phasecong3(self, img: np.ndarray) -> _PhaseCong3Result: """Compute phase congruency via log-Gabor filters. Implementation follows Kovesi's phasecong3 algorithm with corrections from the Julia reference implementation (ImagePhaseCongruency.jl). Args: img: 2D grayscale image as numpy array. Returns: _PhaseCong3Result containing M, m, orientation, feature_type, T, pc_sum. """ img = np.asarray(img, dtype=np.float64) rows, cols = img.shape epsilon = 1e-5 # Julia uses 1e-5 # Construct filter grids (quadrant-shifted, DC at corners) radius, sintheta, costheta, freq = self._construct_filter_grids(rows, cols) # Construct radial component of log-Gabor filters log_gabor_list = self._construct_log_gabor_filters(radius) # Construct angular components using cosine filter (Julia reference) angular_spread = self._compute_angular_spread(sintheta, costheta) # Get FFT of image image_fft = fft2(img) # Initialize accumulators cov_x2 = np.zeros((rows, cols), dtype=np.float64) cov_y2 = np.zeros((rows, cols), dtype=np.float64) cov_xy = np.zeros((rows, cols), dtype=np.float64) energy_v = np.zeros((rows, cols, 3), dtype=np.float64) pc_sum = np.zeros((rows, cols), dtype=np.float64) # Per-scale real/imag storage, reused each orientation (replaces # n_scale x n_orient complex128 EO list — saves ~3.8 GB at 3000x4000) eo_real = np.empty((self.n_scale, rows, cols), dtype=np.float64) eo_imag = np.empty((self.n_scale, rows, cols), dtype=np.float64) # Noise threshold estimation T: float = 0.0 # Process each orientation for o in range(self.n_orient): angle = o * np.pi / self.n_orient # Accumulators for this orientation sum_even = np.zeros((rows, cols), dtype=np.float64) sum_odd = np.zeros((rows, cols), dtype=np.float64) sum_amplitude = np.zeros((rows, cols), dtype=np.float64) max_amplitude = np.zeros((rows, cols), dtype=np.float64) # Initialize tau for this orientation (matches Julia logic) tau: float = 0.0 for s in range(self.n_scale): # Combined filter: log-Gabor radial * angular spread filter_combined = log_gabor_list[s] * angular_spread[o] # Apply filter in frequency domain filtered_fft = image_fft * filter_combined # Transform back to spatial domain — store real/imag separately # to avoid retaining complex128 arrays across orientations eo_complex = ifft2(filtered_fft) amplitude = np.abs(eo_complex) eo_real[s] = eo_complex.real eo_imag[s] = eo_complex.imag # Accumulate responses sum_even += eo_real[s] sum_odd += eo_imag[s] sum_amplitude += amplitude max_amplitude = np.maximum(max_amplitude, amplitude) # Noise estimation from smallest scale (s=0), per orientation if s == 0 and self.noise_method < 0: if abs(self.noise_method + 1) < epsilon: # Median-based estimation tau = float(np.median(amplitude) / np.sqrt(np.log(4))) elif abs(self.noise_method + 2) < epsilon: # Mode-based Rayleigh estimation tau = self._rayleigh_mode(amplitude) # Compute noise threshold T for this orientation if self.noise_method >= 0: T = self.noise_method else: # Total tau across scales (geometric series) if tau > 0: total_tau = tau * (1 - (1 / self.mult) ** self.n_scale) / ( 1 - 1 / self.mult ) # Expected noise energy from Rayleigh distribution mean_energy = total_tau * np.sqrt(np.pi / 2) sigma_energy = total_tau * np.sqrt((4 - np.pi) / 2) T = mean_energy + self.k * sigma_energy else: T = 0.0 # Compute unit-normalized mean direction (Julia reference: XEnergy normalization) # MeanE and MeanO form a unit vector pointing in mean phase direction x_energy = np.sqrt(sum_even ** 2 + sum_odd ** 2) + epsilon mean_even = sum_even / x_energy mean_odd = sum_odd / x_energy # Compute energy with cross-term subtraction (Julia reference) # Sequential loop avoids large temporaries from vectorized sum energy = np.zeros((rows, cols), dtype=np.float64) for s in range(self.n_scale): even = eo_real[s] odd = eo_imag[s] energy += ( even * mean_even + odd * mean_odd - np.abs(even * mean_odd - odd * mean_even) ) # Accumulate energy vectors for orientation/feature_type (Julia reference) energy_v[:, :, 0] += sum_even energy_v[:, :, 1] += np.cos(angle) * sum_odd energy_v[:, :, 2] += np.sin(angle) * sum_odd # Frequency spread weighting (Julia reference) # Width measures how spread out the frequency responses are width = (sum_amplitude / (max_amplitude + epsilon) - 1) / (self.n_scale - 1) weight = 1.0 / (1.0 + np.exp((self.cutoff - width) * self.g)) # Phase congruency for this orientation (local variable, not stored # in a 3D array — saves ~0.58 GB at 3000x4000) pc_o = weight * np.maximum(energy - T, 0) / (sum_amplitude + epsilon) # Accumulate covariance tensor components cos_angle = np.cos(angle) sin_angle = np.sin(angle) pc_sum += pc_o # Square pc_o for covariance (matches Julia PCo^2) pc_sq = pc_o ** 2 cov_x2 += pc_sq * cos_angle * cos_angle cov_y2 += pc_sq * sin_angle * sin_angle cov_xy += pc_sq * cos_angle * sin_angle # Normalize covariance (Julia reference) cov_x2 /= self.n_orient / 2 cov_y2 /= self.n_orient / 2 cov_xy *= 4.0 / self.n_orient # Eigenvalue analysis of covariance tensor denom = np.sqrt(cov_xy ** 2 + (cov_x2 - cov_y2) ** 2) + epsilon # Maximum and minimum moments M = (cov_x2 + cov_y2 + denom) / 2 m = (cov_x2 + cov_y2 - denom) / 2 # Ensure non-negative M = np.maximum(M, 0) m = np.maximum(m, 0) # Orientation (Julia reference: atan(-EnergyV[:,:,3]./EnergyV[:,:,2])) # Julia uses single-argument atan which gives [-pi/2, pi/2] range. # We use arctan for consistency, with safe division handling. with np.errstate(divide="ignore", invalid="ignore"): orientation = np.arctan(-energy_v[:, :, 2] / energy_v[:, :, 1]) # Handle NaN/Inf from division by zero (vertical edges) orientation = np.nan_to_num(orientation, nan=0.0, posinf=np.pi / 2, neginf=-np.pi / 2) # Feature type (Julia reference) odd_v = np.sqrt(energy_v[:, :, 1] ** 2 + energy_v[:, :, 2] ** 2) feature_type = np.arctan2(energy_v[:, :, 0], odd_v) # Note: pc_sum normalization by n_orient is a Python-specific addition. # Julia's phasecong3 doesn't return pc_sum. Normalizing keeps values # in [0,1] range regardless of n_orient, making it suitable for detect_mat. return _PhaseCong3Result( M=M, m=m, orientation=orientation, feature_type=feature_type, T=T, pc_sum=pc_sum / self.n_orient, ) def _construct_filter_grids( self, rows: int, cols: int ) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray]: """Construct frequency domain grids for filter construction. Grids are quadrant-shifted so DC component is at [0, 0]. Follows Julia filtergrids() implementation for odd/even handling. Args: rows: Number of rows in image. cols: Number of columns in image. Returns: Tuple of (radius, sintheta, costheta, freq) where: - radius: Radial frequency normalized [0, 0.5] with DC=1 to avoid div/0 - sintheta: fx/freq grid for angular filter (Julia gridangles) - costheta: fy/freq grid for angular filter (Julia gridangles) - freq: Original radial frequency with DC=0 """ # Frequency coordinates - Julia handles odd/even differently if cols % 2 == 1: # odd fx_range = np.arange(-(cols - 1) / 2, (cols - 1) / 2 + 1) / cols else: # even fx_range = np.arange(-cols / 2, cols / 2) / cols if rows % 2 == 1: # odd fy_range = np.arange(-(rows - 1) / 2, (rows - 1) / 2 + 1) / rows else: # even fy_range = np.arange(-rows / 2, rows / 2) / rows # Quadrant shift so DC is at [0,0] fx_range = ifftshift(fx_range) fy_range = ifftshift(fy_range) fx, fy = np.meshgrid(fx_range, fy_range) # Radial frequency freq = np.sqrt(fx ** 2 + fy ** 2) # For log-Gabor, need radius with DC=1 to avoid log(0) radius = freq.copy() radius[0, 0] = 1.0 # Compute sintheta and costheta for angular filters (Julia gridangles) # Temporarily set freq DC to 1 to avoid divide by zero freq_safe = freq.copy() freq_safe[0, 0] = 1.0 sintheta = fx / freq_safe costheta = fy / freq_safe # Restore DC values sintheta[0, 0] = 0.0 costheta[0, 0] = 0.0 return radius, sintheta, costheta, freq def _construct_log_gabor_filters(self, radius: np.ndarray) -> List[np.ndarray]: """Construct log-Gabor filters for each scale. Log-Gabor filters have Gaussian transfer functions on a logarithmic frequency scale, providing constant shape ratio across scales. Args: radius: Radial frequency grid. Returns: List of n_scale log-Gabor filter arrays. """ log_gabor_list = [] # Lowpass filter depends only on radius, not scale — compute once lowpass = 1.0 / (1.0 + (radius / 0.45) ** 30) for s in range(self.n_scale): wavelength = self.min_wavelength * (self.mult ** s) f0 = 1.0 / wavelength # Center frequency # Log-Gabor transfer function with np.errstate(divide="ignore", invalid="ignore"): log_rad_over_f0 = np.log(radius / f0) log_gabor = np.exp( -(log_rad_over_f0 ** 2) / (2 * np.log(self.sigma_onf) ** 2) ) # Zero out DC component log_gabor[0, 0] = 0 log_gabor_list.append(log_gabor * lowpass) return log_gabor_list def _compute_angular_spread( self, sintheta: np.ndarray, costheta: np.ndarray ) -> List[np.ndarray]: """Compute angular spreading functions using cosine filter (Julia reference). Uses cosineangularfilter from Julia ImagePhaseCongruency.jl which computes angular distance via atan2 of sin/cos differences for proper wrap-around. Args: sintheta: fx/freq grid from _construct_filter_grids. costheta: fy/freq grid from _construct_filter_grids. Returns: List of n_orient angular spread arrays. """ angular_spread_list = [] # Wavelength for cosine window function (Julia reference: 4*pi/norient) wavelen = 4.0 * np.pi / self.n_orient for o in range(self.n_orient): angle = o * np.pi / self.n_orient sinangl = np.sin(angle) cosangl = np.cos(angle) # Angular distance using sin/cos difference (Julia cosineangularfilter) # This handles wrap-around correctly via atan2 ds = sintheta * cosangl - costheta * sinangl # Difference in sine dc = costheta * cosangl + sintheta * sinangl # Difference in cosine dtheta = np.abs(np.arctan2(ds, dc)) # Absolute angular distance # Scale theta for cosine window and clamp to pi dtheta = np.minimum(dtheta * 2.0 * np.pi / wavelen, np.pi) # Cosine window: (cos(dtheta) + 1) / 2 gives values in [0, 1] spread = (np.cos(dtheta) + 1.0) / 2.0 angular_spread_list.append(spread) return angular_spread_list def _rayleigh_mode(self, amplitude: np.ndarray) -> float: """Estimate Rayleigh distribution parameter from amplitude data. For filter responses to Gaussian noise, amplitudes follow a Rayleigh distribution. The mode of a Rayleigh distribution equals sigma. Args: amplitude: Array of amplitude values. Returns: Estimated Rayleigh sigma parameter. """ # Flatten and remove zeros amp_flat = amplitude.flatten() amp_flat = amp_flat[amp_flat > 0] if len(amp_flat) == 0: return 0.0 # Histogram-based mode estimation # Match Julia: uses 50 bins n_bins = 50 hist, bin_edges = np.histogram(amp_flat, bins=n_bins) bin_centers = (bin_edges[:-1] + bin_edges[1:]) / 2 # Find mode (peak of histogram) mode_idx = np.argmax(hist) mode_value = bin_centers[mode_idx] # For Rayleigh distribution, mode = sigma return float(mode_value)