Source code for phenotypic.refine._trim_asymmetry

from __future__ import annotations

from typing import TYPE_CHECKING, Annotated, Literal

if TYPE_CHECKING:
    from skimage.measure._regionprops import RegionProperties

    from phenotypic._core._image import Image

import logging

import numpy as np
from pydantic import field_validator
from scipy.ndimage import distance_transform_edt, label as ndi_label
from skimage.measure import euler_number, regionprops

from ..abc_ import ObjectRefiner
from ..measure._zone_segmentation import (
    compute_radial_density_profile,
    compute_sholl_angular_profile,
    distance_from_point,
    find_core_radius,
    find_symmetric_radius,
)
from ..sdk_.typing_ import TuneSpec

_log = logging.getLogger(__name__)


[docs] class TrimAsymmetry(ObjectRefiner): """Trim asymmetric spurs and reticulated noise beyond each colony's symmetric radius. Computes a per-colony symmetric radius (``R_sym``) using equal-area radial annuli and angular-sector coverage profiling, then removes mask pixels beyond that radius that belong to topologically noisy or geometrically spurious connected components. When ``beehive_threshold`` is set, linearly branched structures (genuine hyphae, zero topological holes) are preserved while web-like reticulated components are discarded based on their holes-per-pixel density. For the underlying symmetric-radius algorithm, see :doc:`/explanation/refinement_strategies`. Best For: - Filamentous fungi detections where Dijkstra-reconnected bridges produce thin asymmetric spurs extending past the colony body. - Plates where reticulated or web-like mask artefacts surround otherwise symmetric colony cores. - Compact yeast or bacterial colonies that acquired noise appendages from high-contrast agar texture during detection. - Pipeline stages after :class:`FilamentousFungiDetector` where reconnection loops must be removed while linear hyphae are kept. Consider Also: - :class:`SmallObjectRemover` when spurious fragments are distinguishable by size alone rather than by radial position relative to the colony centre. - :class:`RemoveLowCircularity` when the entire colony shape should be judged against a circularity threshold rather than a radial symmetric envelope. - :class:`MaskErosion` for a simpler boundary retraction when the asymmetric extent is shallow and uniform around the colony perimeter. - :class:`MergeFragmentChains` when asymmetric components are genuine but fragmented, and should be reconnected rather than removed. Args: symmetry_threshold: Minimum fraction of ``n_angular_bins`` sectors that must contain mask pixels for an annulus to count as symmetric. Lower values push ``R_sym`` outward (less trimming); higher values pull it inward (more trimming). Practical window: 0.33--0.83. Default: 0.5 (3/6), intentionally looser than :class:`MeasureSymmetricZones` (4/6) so ``R_sym`` stays near the true colony edge rather than the inoculum core. On small colonies (radius < 30 px) with a coarse 6-sector grid, consider reducing ``n_angular_bins`` to 4 rather than lowering this threshold. n_angular_bins: Number of equal-width angular sectors (360°/n each) for the coverage diagnostic. Fewer bins (default 6, 60° each) give stable coverage estimates on small colonies; more bins resolve subtle sector-level asymmetry on large colonies. Typical range: 4--12. Default: 6. n_annuli: Target number of equal-area radial annuli for profiling. Auto-clamped to ``max(6, min(n_annuli, max_pixel_radius))``, so increasing beyond ``max_pixel_radius`` has no effect. More annuli give finer PELT changepoint and ``R_sym`` resolution at proportionally higher runtime. Typical range: 10--200. Default: 100. pelt_penalty: L2 changepoint penalty for PELT inoculum-core detection. Lower values detect more changepoints and place the core boundary closer to the inoculum; higher values suppress changepoints (no detection sets ``core_radius=0``). The penalty is on the BIC scale, of order ``log(n_annuli)`` for the mean-change (``l2``) model [1]; raise it on noisy or fragmented masks to suppress spurious cores and lower it when a diffuse inoculum-to-colony transition needs an earlier boundary. Typical range: 1.0--20.0. Default: 5.0. smoothing_window: Moving-average window (annuli) applied to the angular-coverage profile before the ``R_sym`` threshold test. Larger windows prevent ``R_sym`` from collapsing due to isolated empty annuli from thin or fragmented masks; 1 disables smoothing. Typical range: 1--10. Default: 3. method: Inoculum-centre estimator for the distance map. ``"distance"`` uses the Euclidean distance-transform peak (most inscribed point), robust on any input including all-zero grayscale. ``"intensity"`` uses the intensity-weighted centroid of the grayscale crop, falling back to the distance peak when grayscale is flat or zero. Accepted values: ``"distance"``, ``"intensity"``. Default: ``"distance"``. beehive_threshold: Minimum holes-per-pixel density ``max(0, 1 - euler_number) / area`` required to remove a candidate connected component. ``None`` removes every component past ``R_sym`` regardless of topology. Positive values preserve linear hyphae (zero topological holes) while removing reticulated web-like components whose hole density exceeds the threshold. Practical range: 0.0--0.05; a reasonable starting point is ``0.002``, which removes components with roughly one hole per 500 pixels while protecting genuine hyphae. Default: ``None``. min_cc_area: Minimum area (pixels) of an asymmetric connected component for a topology-based removal decision; smaller components are kept unchanged because Euler-number statistics are unreliable on tiny regions. Scale to 100--200 px on high-resolution scans. Typical range: 1--500. Default: 50. min_object_area: Minimum colony area (pixels) before any trimming is attempted; smaller colonies are skipped entirely to avoid degenerate geometry in the radial pipeline. Typical range: 10--10 000. Default: 100. Returns: Image: Input image with ``objmap`` updated to remove asymmetric mask pixels beyond ``R_sym``; ``objmask`` refreshes automatically via the accessor. All other image components are unchanged. Raises: ValueError: If ``symmetry_threshold`` is outside ``[0, 1]``. ValueError: If ``beehive_threshold`` is negative. References: [1] R. Killick, P. Fearnhead, and I. A. Eckley, "Optimal detection of changepoints with a linear computational cost," *J. Amer. Statist. Assoc.*, vol. 107, no. 500, pp. 1590--1598, Dec. 2012. See Also: :doc:`/how_to/notebooks/refine_noisy_boundaries` for a visual walkthrough of spur and reticulated noise removal on real plate images. :doc:`/explanation/refinement_strategies` for the symmetric-radius algorithm and per-colony topology classification. """ symmetry_threshold: Annotated[float, TuneSpec(0.33, 0.83)] = 3 / 6 n_angular_bins: Annotated[int, TuneSpec(4, 12)] = 6 n_annuli: Annotated[int, TuneSpec(10, 200, log=True)] = 100 pelt_penalty: Annotated[float, TuneSpec(1.0, 20.0, log=True)] = 5.0 smoothing_window: Annotated[int, TuneSpec(1, 10)] = 3 method: Literal["distance", "intensity"] = "distance" beehive_threshold: Annotated[float | None, TuneSpec(0.0, 0.05)] = None min_cc_area: Annotated[int, TuneSpec(1, 500, log=True)] = 50 min_object_area: Annotated[int, TuneSpec(10, 10_000, log=True)] = 100 @field_validator("symmetry_threshold") @classmethod def _validate_symmetry_threshold(cls, symmetry_threshold: float) -> float: """Reject a ``symmetry_threshold`` outside ``[0, 1]``. Reproduces the pre-migration ``__init__`` guard verbatim. """ if not 0.0 <= symmetry_threshold <= 1.0: raise ValueError( "symmetry_threshold must be in [0, 1]; " f"got {symmetry_threshold}." ) return symmetry_threshold @field_validator("beehive_threshold") @classmethod def _validate_beehive_threshold( cls, beehive_threshold: float | None ) -> float | None: """Reject a negative ``beehive_threshold``. Reproduces the pre-migration ``__init__`` guard verbatim. """ if beehive_threshold is not None and beehive_threshold < 0.0: raise ValueError( "beehive_threshold must be non-negative or None; " f"got {beehive_threshold}." ) return beehive_threshold def _operate(self, image: Image) -> Image: """Trim asymmetric spurs / beehive noise from each colony in the objmap. Args: image: Detected image with a populated ``objmap``. Returns: Image: Same image with ``objmap`` updated in place. """ objmap = image.objmap[:] if objmap.max() == 0: return image # Work on a copy we can paint zeros into; write back once at the end. modified = objmap.copy() # Intensity-weighted centroid needs a gray source. gray_full = image.gray[:] if self.method == "intensity" else None for prop in regionprops(objmap): if prop.area < self.min_object_area: continue slc = prop.slice objmap_crop = modified[slc] obj_mask = objmap_crop == prop.label to_remove = self._trim_mask_for_object( obj_mask=obj_mask, prop=prop, gray_full=gray_full, ) if to_remove is None or not to_remove.any(): continue objmap_crop[to_remove] = 0 image.objmap[:] = modified return image def _trim_mask_for_object( self, obj_mask: np.ndarray, prop: "RegionProperties", gray_full: np.ndarray | None, ) -> np.ndarray | None: """Compute the boolean "pixels to remove" mask for one colony crop. Args: obj_mask: Boolean crop of the object in its bounding box. prop: ``skimage.measure._regionprops.RegionProperties`` instance from iterating ``regionprops(objmap)``; used for ``slice``, ``label``, and ``centroid_weighted``. gray_full: Full-image grayscale used for intensity-weighted centroid (``method="intensity"``). ``None`` in distance mode. Returns: A boolean array the same shape as ``obj_mask`` with ``True`` at pixels to remove, or ``None`` when the colony is ineligible (no asymmetric region or degenerate geometry). """ # 1. Centroid (local bbox coords). Distance-transform peak is a safe # fallback; intensity mode uses it when the intensity is all zero and # regionprops produces a NaN weighted centroid. dt = distance_transform_edt(obj_mask) peak_idx = np.unravel_index(int(np.argmax(dt)), obj_mask.shape) distance_centroid = (float(peak_idx[0]), float(peak_idx[1])) if self.method == "distance": centroid_rc = distance_centroid else: assert gray_full is not None slc = prop.slice gray_crop = gray_full[slc] intensity_crop = gray_crop if gray_crop.ndim == 3: intensity_crop = gray_crop[..., 0] local_props = regionprops( obj_mask.astype(np.int32), intensity_image=intensity_crop, ) if not local_props: return None cw = local_props[0].centroid_weighted if np.isnan(cw[0]) or np.isnan(cw[1]): # Intensity is flat/zero on this crop; fall back to distance peak. centroid_rc = distance_centroid else: centroid_rc = (float(cw[0]), float(cw[1])) # 2. Distance map from centroid. dist_map = distance_from_point( obj_mask.shape, centroid_rc ) mask_distances = dist_map[obj_mask] if mask_distances.size == 0: return None max_pixel_radius = int(mask_distances.max()) if max_pixel_radius <= 0: return None effective_annuli = max(6, min(self.n_annuli, max_pixel_radius)) # 3. R_sym pipeline. density_profile, annulus_radii = ( compute_radial_density_profile( obj_mask, dist_map, effective_annuli, ) ) if annulus_radii.size == 0: return None try: core_radius = find_core_radius( density_profile, annulus_radii, self.pelt_penalty, ) except Exception: # pragma: no cover — ruptures failure is rare _log.debug( "PELT changepoint detection failed for label %d; " "treating core as zero.", prop.label, exc_info=True, ) core_radius = 0.0 _, _, angular_coverage = ( compute_sholl_angular_profile( obj_mask, dist_map, centroid_rc, annulus_radii, self.n_angular_bins, ) ) r_sym = find_symmetric_radius( annulus_radii, angular_coverage, core_radius, self.symmetry_threshold, self.smoothing_window, ) # 4. Asymmetric region. asymmetric_region = obj_mask & (dist_map > r_sym) if not asymmetric_region.any(): return None # 5. Per-CC decision. cc_map, n_cc = ndi_label(asymmetric_region) if n_cc == 0: return None to_remove = np.zeros_like(obj_mask, dtype=bool) for cc_id in range(1, n_cc + 1): cc_mask = cc_map == cc_id cc_area = int(cc_mask.sum()) if cc_area < self.min_cc_area: continue if self.beehive_threshold is None: to_remove |= cc_mask continue holes = max(0, 1 - int(euler_number(cc_mask, connectivity=2))) density = holes / float(cc_area) if density >= self.beehive_threshold: to_remove |= cc_mask if not to_remove.any(): return None return to_remove