Source code for phenotypic.util.image_metrics

"""Image quality metrics for colony detection analysis.

This module provides comprehensive image quality assessment tools for
evaluating detection matrices before colony detection. Metrics include
noise estimation, contrast analysis, structural coherence, and
background uniformity assessment.

The :class:`ImageMetricsCalculator` computes metrics used by the
:class:`DiagnosticsPlotter` for both its static matplotlib figures and
its interactive ``image.plot.dash.diagnostics()`` views.

Examples:
    >>> from phenotypic.data import load_synth_yeast_plate
    >>> from phenotypic.util.image_metrics import ImageMetricsCalculator
    >>> image = load_synth_yeast_plate()
    >>> calc = ImageMetricsCalculator(image.detect_mat[:])
    >>> noise = calc.compute_noise_metrics()
    >>> print(f"SNR: {noise['snr']:.2f}")
    SNR: ...
"""

from __future__ import annotations

from typing import Any, Literal, TypedDict

import numpy as np
from scipy import fft as scipy_fft
from scipy.ndimage import gaussian_filter, uniform_filter
from skimage.feature import structure_tensor, structure_tensor_eigenvalues
from skimage.filters import frangi, hessian, meijering

# Global thresholds for metric interpretation
THRESHOLDS = {
    "snr"          : {"critical": 3.0, "marginal": 7.0},
    "rms_contrast" : {"critical": 0.02, "marginal": 0.05},
    "coherence"    : {"critical": 0.15, "marginal": 0.30},
    "nonuniformity": {"critical": 0.50, "marginal": 0.20},
}


[docs] class NoiseMetrics(TypedDict): """Noise-related metrics from detection matrix analysis.""" snr: float sigma_mad: float correlation_length: float
[docs] class ContrastMetrics(TypedDict): """Contrast-related metrics from detection matrix analysis.""" rms_contrast: float michelson: float dynamic_range: float p1: float p99: float
[docs] class StructureMetrics(TypedDict): """Structure tensor and ridge detection metrics.""" mean_coherence: float optimal_scale: float peak_response: float ridge_responses: list[float] scales: list[float] ridge_method: str coherence_map: np.ndarray | None # Non-serializable
[docs] class BackgroundMetrics(TypedDict): """Background uniformity metrics.""" nonuniformity_ratio: float mean_gradient: float background_estimate: np.ndarray | None # Non-serializable
[docs] class QualityScores(TypedDict): """Normalized 0-1 quality scores for radar chart visualization.""" SNR: float Contrast: float Coherence: float Uniformity: float Sharpness: float
[docs] class ImageMetricsCalculator: """Computes image quality metrics from detection matrices. This class extracts noise, contrast, structure, and background metrics used to assess image quality for colony detection. It provides the computational core shared by both the static matplotlib and the interactive Plotly diagnostics visualizations. Args: detect_mat: 2D grayscale detection matrix (typically ``image.detect_mat[:]``). Attributes: bit_depth: Image bit depth (8 or 16) inferred from max intensity. Examples: >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.util.image_metrics import ImageMetricsCalculator >>> image = load_synth_yeast_plate() >>> calc = ImageMetricsCalculator(image.detect_mat[:]) >>> noise = calc.compute_noise_metrics() >>> print(f"SNR: {noise['snr']:.2f}") SNR: ... >>> contrast = calc.compute_contrast_metrics() >>> print(f"RMS contrast: {contrast['rms_contrast']:.3f}") RMS contrast: ... """ def __init__(self, detect_mat: np.ndarray) -> None: self._detect_mat = detect_mat.astype(np.float64) self._max_intensity = 255.0 if detect_mat.max() <= 255 else 65535.0 @property def bit_depth(self) -> int: """Image bit depth (8 or 16) inferred from max intensity.""" return 8 if self._max_intensity == 255.0 else 16 @property def max_intensity(self) -> float: """Maximum intensity value based on image bit depth.""" return self._max_intensity # ========================================================================= # HELPER METHODS # ========================================================================= def _compute_autocorrelation(self, img: np.ndarray) -> np.ndarray: """Compute 2D autocorrelation using FFT. Args: img: Input image array. Returns: 2D autocorrelation array (cropped to central region). """ # Pad to avoid circular correlation artifacts padded = np.pad(img - np.mean(img), ((0, img.shape[0]), (0, img.shape[1]))) # FFT-based autocorrelation f = scipy_fft.fft2(padded) power = np.abs(f) ** 2 autocorr = np.real(scipy_fft.ifft2(power)) # Shift zero-lag to center and crop autocorr = scipy_fft.fftshift(autocorr) h, w = img.shape ch, cw = autocorr.shape[0] // 2, autocorr.shape[1] // 2 crop_size = min(50, h // 4, w // 4) autocorr_cropped = autocorr[ ch - crop_size: ch + crop_size, cw - crop_size: cw + crop_size ] return autocorr_cropped
[docs] def compute_psd(self, img: np.ndarray | None = None) -> tuple[ np.ndarray, np.ndarray]: """Compute radially averaged power spectral density. Args: img: Input image array. If None, uses the stored detection matrix. Returns: Tuple of (frequencies, radial_psd). """ if img is None: img = self._detect_mat # Compute 2D FFT f = scipy_fft.fft2(img - np.mean(img)) f_shifted = scipy_fft.fftshift(f) psd_2d = np.abs(f_shifted) ** 2 # Radial averaging h, w = psd_2d.shape cy, cx = h // 2, w // 2 y, x = np.ogrid[:h, :w] r = np.sqrt((x - cx) ** 2 + (y - cy) ** 2).astype(int) # Bin by radius max_r = min(cx, cy) radial_sum = np.bincount(r.ravel(), weights=psd_2d.ravel()) radial_count = np.bincount(r.ravel()) radial_psd = radial_sum[: max_r + 1] / (radial_count[: max_r + 1] + 1e-10) # Frequency axis (normalized) freqs = np.arange(len(radial_psd)) / len(radial_psd) return freqs, radial_psd
[docs] def compute_local_contrast( self, img: np.ndarray | None = None, window_size: int = 15 ) -> np.ndarray: """Compute local Weber contrast map. Args: img: Input image array. If None, uses the stored detection matrix. window_size: Size of local window. Returns: Local contrast map. """ if img is None: img = self._detect_mat img_float = img.astype(np.float64) # Local mean local_mean = uniform_filter(img_float, size=window_size) # Local contrast (Weber-like) contrast = np.abs(img_float - local_mean) / (local_mean + 1e-10) return contrast
[docs] def compute_local_variance( self, img: np.ndarray | None = None, window_size: int = 15 ) -> np.ndarray: """Compute local variance map. Args: img: Input image array. If None, uses the stored detection matrix. window_size: Size of local window. Returns: Local variance map. """ if img is None: img = self._detect_mat img_float = img.astype(np.float64) # Local mean and mean of squares local_mean = uniform_filter(img_float, size=window_size) local_mean_sq = uniform_filter(img_float ** 2, size=window_size) # Variance = E[X^2] - E[X]^2 variance = np.maximum(local_mean_sq - local_mean ** 2, 0) return variance
# ========================================================================= # METRIC COMPUTATION METHODS # =========================================================================
[docs] def compute_noise_metrics(self) -> NoiseMetrics: """Compute noise-related metrics (parameter-free). Returns: Dictionary with SNR, sigma_mad, and correlation_length. """ img = self._detect_mat # Estimate noise using high-pass residual (more robust than Laplacian MAD) # For synthetic images with large uniform regions, Laplacian MAD can be 0 smoothed = gaussian_filter(img, sigma=2.0) residual = img - smoothed # Use robust MAD estimator, falling back to std if MAD is too small mad_val = np.median(np.abs(residual - np.median(residual))) sigma_mad = mad_val / 0.6745 if mad_val > 1e-10 else np.std(residual) # Ensure minimum noise estimate (prevents division by near-zero) sigma_mad = max(sigma_mad, 1e-6) # Signal estimation (mean of image) signal_mean = np.mean(img) # SNR calculation snr = signal_mean / sigma_mad # Correlation length from autocorrelation # Compute half-width at half-maximum autocorr = self._compute_autocorrelation(img) center_h = autocorr.shape[0] // 2 # Extract horizontal profile through center profile = autocorr[center_h, :] profile_norm = profile / (profile.max() + 1e-10) # Find half-width at half-maximum half_max_indices = np.where(profile_norm >= 0.5)[0] if len(half_max_indices) > 1: correlation_length = (half_max_indices[-1] - half_max_indices[0]) / 2.0 else: correlation_length = 1.0 return NoiseMetrics( snr=float(snr), sigma_mad=float(sigma_mad), correlation_length=float(correlation_length), )
[docs] def compute_contrast_metrics(self) -> ContrastMetrics: """Compute contrast-related metrics (parameter-free). Returns: Dictionary with rms_contrast, michelson, dynamic_range, p1, and p99. """ img = self._detect_mat # RMS contrast (std / mean) - bit-depth agnostic mean_val = np.mean(img) std_val = np.std(img) rms_contrast = std_val / (mean_val + 1e-10) # Michelson contrast using percentiles (more robust than min/max) p1 = np.percentile(img, 1) p99 = np.percentile(img, 99) michelson = (p99 - p1) / (p99 + p1 + 1e-10) # Dynamic range (normalized by max possible intensity) min_val = img.min() max_val = img.max() dynamic_range = (max_val - min_val) / self._max_intensity return ContrastMetrics( rms_contrast=float(rms_contrast), michelson=float(michelson), dynamic_range=float(dynamic_range), p1=float(p1), p99=float(p99), )
[docs] def compute_structure_metrics( self, sigma: float = 1.5, scales: list[float] | None = None, ridge_method: Literal["meijering", "frangi", "hessian"] = "meijering", ) -> StructureMetrics: """Compute structure tensor and ridge detection metrics. Args: sigma: Sigma for structure tensor computation. scales: List of scales for multiscale ridge detection. Defaults to [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0]. ridge_method: Method for ridge detection: "meijering" (default), "frangi", or "hessian". Returns: Dictionary with mean_coherence, optimal_scale, peak_response, ridge_responses, scales, ridge_method, and coherence_map. """ if scales is None: scales = [0.5, 1.0, 1.5, 2.0, 3.0, 4.0, 5.0] img = self._detect_mat / self._max_intensity # Compute structure tensor and coherence A_elems = structure_tensor(img, sigma=sigma) l1, l2 = structure_tensor_eigenvalues(A_elems) # Coherence: (l1 - l2) / (l1 + l2 + eps) coherence = (l1 - l2) / (l1 + l2 + 1e-10) mean_coherence = float(np.mean(coherence)) # Multiscale ridge detection ridge_responses = [] for scale in scales: if ridge_method == "meijering": response = meijering(img, sigmas=[scale], black_ridges=False) elif ridge_method == "frangi": response = frangi(img, sigmas=[scale], black_ridges=False) else: # hessian response = hessian(img, sigmas=[scale], black_ridges=False) ridge_responses.append(float(np.mean(response))) # Find optimal scale if ridge_responses: optimal_idx = np.argmax(ridge_responses) optimal_scale = scales[optimal_idx] peak_response = ridge_responses[optimal_idx] else: optimal_scale = scales[0] if scales else 1.0 peak_response = 0.0 return StructureMetrics( mean_coherence=mean_coherence, optimal_scale=float(optimal_scale), peak_response=float(peak_response), ridge_responses=ridge_responses, scales=scales, ridge_method=ridge_method, coherence_map=coherence, )
[docs] def compute_background_metrics(self, sigma: float = 50.0) -> BackgroundMetrics: """Compute background uniformity metrics. Args: sigma: Sigma for background estimation (Gaussian smoothing). Returns: Dictionary with nonuniformity_ratio, mean_gradient, and background_estimate. """ img = self._detect_mat # Background estimate via large-sigma Gaussian smoothing background = gaussian_filter(img, sigma=sigma) # Nonuniformity ratio (std / mean of background) bg_mean = np.mean(background) bg_std = np.std(background) nonuniformity_ratio = bg_std / (bg_mean + 1e-10) # Mean gradient magnitude of background grad_x = np.gradient(background, axis=1) grad_y = np.gradient(background, axis=0) grad_mag = np.sqrt(grad_x ** 2 + grad_y ** 2) mean_gradient = float(np.mean(grad_mag)) return BackgroundMetrics( nonuniformity_ratio=float(nonuniformity_ratio), mean_gradient=mean_gradient, background_estimate=background, )
[docs] def compute_quality_scores( self, noise: NoiseMetrics, contrast: ContrastMetrics, structure: StructureMetrics, background: BackgroundMetrics, ) -> QualityScores: """Compute normalized 0-1 quality scores for radar chart. Args: noise: Noise metrics from compute_noise_metrics(). contrast: Contrast metrics from compute_contrast_metrics(). structure: Structure metrics from compute_structure_metrics(). background: Background metrics from compute_background_metrics(). Returns: Dictionary of quality scores (SNR, Contrast, Coherence, Uniformity, Sharpness), all normalized to [0, 1]. """ # SNR score (saturates at 15) snr_score = min(noise["snr"] / 15.0, 1.0) # Contrast score (RMS contrast, saturates at 0.15) contrast_score = min(contrast["rms_contrast"] / 0.15, 1.0) # Coherence score (already 0-1) coherence_score = min(structure["mean_coherence"], 1.0) # Uniformity score (inverse of nonuniformity) uniformity_score = max(1.0 - background["nonuniformity_ratio"] * 2, 0.0) # Sharpness score (based on peak ridge response as proxy) sharpness_score = min(structure["peak_response"] * 5, 1.0) return QualityScores( SNR=snr_score, Contrast=contrast_score, Coherence=coherence_score, Uniformity=uniformity_score, Sharpness=sharpness_score, )
# ========================================================================= # INTERPRETATION AND RECOMMENDATIONS # =========================================================================
[docs] @staticmethod def generate_interpretation( section: Literal["noise", "contrast", "structure", "background"], metrics: dict[str, Any], ) -> str: """Generate human-readable interpretation text. Args: section: Section name ("noise", "contrast", "structure", "background"). metrics: Computed metrics for the section. Returns: Interpretation text string. """ if section == "noise": snr = metrics["snr"] if snr < THRESHOLDS["snr"]["critical"]: quality = "critically low" action = "Strong denoising required (LocalEdgeDenoise, MedianFilter)." elif snr < THRESHOLDS["snr"]["marginal"]: quality = "marginal" action = "Light denoising recommended (GaussianBlur sigma=0.5-1.0)." else: quality = "adequate" action = "No denoising needed." return f"SNR ({snr:.1f}) is {quality} for reliable detection. {action}" elif section == "contrast": rms = metrics["rms_contrast"] if rms < THRESHOLDS["rms_contrast"]["critical"]: quality = "critically low" action = "Strong contrast enhancement required (EnhanceLocalContrast, HistogramEqualization)." elif rms < THRESHOLDS["rms_contrast"]["marginal"]: quality = "marginal" action = "Contrast enhancement recommended (EnhanceLocalContrast clip_limit=2.0)." else: quality = "adequate" action = "Contrast is sufficient for detection." return f"RMS contrast ({rms:.3f}) is {quality}. {action}" elif section == "structure": coherence = metrics["mean_coherence"] if coherence < THRESHOLDS["coherence"]["critical"]: quality = "low" desc = "weak directional structure" elif coherence < THRESHOLDS["coherence"]["marginal"]: quality = "moderate" desc = "some linear features present" else: quality = "high" desc = "strong linear/edge structure" opt_scale = metrics["optimal_scale"] return ( f"Mean coherence ({coherence:.3f}) indicates {desc}. " f"Optimal ridge scale: sigma={opt_scale:.1f} px." ) elif section == "background": nonunif = metrics["nonuniformity_ratio"] if nonunif > THRESHOLDS["nonuniformity"]["critical"]: quality = "severe" action = "Background correction critical (SubtractRollingBall, FlatFieldCorrection)." elif nonunif > THRESHOLDS["nonuniformity"]["marginal"]: quality = "moderate" action = "Background correction recommended." else: quality = "acceptably low" action = "Background is sufficiently uniform." return f"Background non-uniformity ({nonunif:.1%}) is {quality}. {action}" return ""
[docs] @staticmethod def generate_recommendations( noise: NoiseMetrics, contrast: ContrastMetrics, structure: StructureMetrics, background: BackgroundMetrics, ) -> list[str]: """Generate actionable preprocessing recommendations. Args: noise: Noise metrics from compute_noise_metrics(). contrast: Contrast metrics from compute_contrast_metrics(). structure: Structure metrics from compute_structure_metrics(). background: Background metrics from compute_background_metrics(). Returns: List of recommendation strings. """ recommendations = [] # Noise recommendations snr = noise["snr"] corr_len = noise["correlation_length"] if snr < THRESHOLDS["snr"]["critical"]: recommendations.append( "Apply strong denoising: LocalEdgeDenoise(sigma_spatial=3, sigma_intensity=0.1)" ) elif snr < THRESHOLDS["snr"]["marginal"]: recommendations.append("Apply light denoising: GaussianBlur(sigma=0.5-1.0)") if corr_len > 5: recommendations.append( f"Structured noise detected (xi={corr_len:.1f}px). " "Consider MedianFilter or morphological opening." ) # Contrast recommendations rms = contrast["rms_contrast"] dynamic = contrast["dynamic_range"] if rms < THRESHOLDS["rms_contrast"]["critical"]: recommendations.append( "Apply contrast enhancement: EnhanceLocalContrast(clip_limit=3.0) or HistogramEqualization" ) elif rms < THRESHOLDS["rms_contrast"]["marginal"]: recommendations.append( "Consider EnhanceLocalContrast(clip_limit=2.0) for improved contrast") if dynamic < 0.3: recommendations.append( "Low dynamic range detected. Consider ContrastStretching or exposure adjustment." ) # Structure recommendations opt_scale = structure["optimal_scale"] recommendations.append( f"Use sigma_range=[{opt_scale * 0.7:.1f}, {opt_scale * 1.5:.1f}] for multiscale structure detection" ) # Background recommendations nonunif = background["nonuniformity_ratio"] if nonunif > THRESHOLDS["nonuniformity"]["critical"]: recommendations.append( "Apply background correction: SubtractRollingBall(radius=50) or FlatFieldCorrection" ) elif nonunif > THRESHOLDS["nonuniformity"]["marginal"]: recommendations.append( "Consider SubtractGaussian(sigma=50) for background uniformity" ) return recommendations
# ========================================================================= # CONVENIENCE METHOD # =========================================================================
[docs] def compute_all( self, structure_sigma: float = 1.5, ridge_scales: list[float] | None = None, ridge_method: Literal["meijering", "frangi", "hessian"] = "meijering", background_sigma: float = 50.0, include_non_serializable: bool = False, ) -> dict[str, Any]: """Compute all metrics and return comprehensive dict. Args: structure_sigma: Sigma for structure tensor computation. ridge_scales: Scales for ridge detection. ridge_method: Ridge detection algorithm. background_sigma: Sigma for background estimation. include_non_serializable: If False, removes numpy arrays from output. Returns: Dict with keys: bit_depth, noise, contrast, structure, background, quality_scores, interpretations, recommendations. """ # Compute all metrics noise = self.compute_noise_metrics() contrast = self.compute_contrast_metrics() structure = self.compute_structure_metrics( sigma=structure_sigma, scales=ridge_scales, ridge_method=ridge_method, ) background = self.compute_background_metrics(sigma=background_sigma) # Compute quality scores quality_scores = self.compute_quality_scores(noise, contrast, structure, background) # Generate interpretations interpretations = { "noise" : self.generate_interpretation("noise", noise), "contrast" : self.generate_interpretation("contrast", contrast), "structure" : self.generate_interpretation("structure", structure), "background": self.generate_interpretation("background", background), } # Generate recommendations recommendations = self.generate_recommendations( noise, contrast, structure, background ) # Clean non-serializable items if requested if include_non_serializable: structure_out = dict(structure) background_out = dict(background) else: structure_out = {k: v for k, v in structure.items() if k != "coherence_map"} background_out = { k: v for k, v in background.items() if k != "background_estimate" } return { "bit_depth" : self.bit_depth, "noise" : dict(noise), "contrast" : dict(contrast), "structure" : structure_out, "background" : background_out, "quality_scores" : dict(quality_scores), "interpretations": interpretations, "recommendations": recommendations, }
__all__ = [ "ImageMetricsCalculator", "THRESHOLDS", "NoiseMetrics", "ContrastMetrics", "StructureMetrics", "BackgroundMetrics", "QualityScores", ]