Source code for phenotypic.refine._circularity_modifier

from __future__ import annotations

from typing import TYPE_CHECKING

if TYPE_CHECKING:
    from phenotypic import Image

import pandas as pd
import numpy as np
from skimage.measure import regionprops_table
import math

from ..abc_ import ObjectRefiner
from ..tools.constants_ import OBJECT


[docs] class LowCircularityRemover(ObjectRefiner): """Remove objects with circularity below a specified cutoff. Intuition: Single bacterial/fungal colonies on agar often appear approximately round. Irregular, elongated, or fragmented shapes can indicate merged colonies, scratches, agar texture, or segmentation errors. Filtering by circularity keeps well-formed colonies and removes unlikely shapes. Why this is useful for agar plates: Circular colonies produce more reliable area and intensity measurements. Removing low-circularity detections reduces bias from streaks, debris, or incomplete segmentation near plate edges and grid borders. Use cases: - Post-threshold cleanup to exclude elongated artifacts or merged colonies before counting or phenotyping. - Enforcing morphology consistency in high-throughput grid assays. Caveats: - Some colonies are intrinsically irregular (wrinkled, spreading, filamentous). A high cutoff may incorrectly remove these phenotypes. - Perimeter estimates on low-resolution masks can be noisy, slightly biasing the circularity calculation. Attributes: cutoff (float): Minimum Polsby–Popper circularity required to keep an object, in [0, 1]. Higher values retain only near-circular shapes (sharper shape constraints) and can improve edge sharpness in the kept set but may reduce recall for irregular colonies. Examples: >>> from phenotypic.refine import LowCircularityRemover >>> op = LowCircularityRemover(cutoff=0.8) >>> image = op.apply(image, inplace=True) # doctest: +SKIP """
[docs] def __init__(self, cutoff: float = 0.785): """Initialize the remover. Args: cutoff (float): Minimum allowed circularity in [0, 1]. Increasing the cutoff favors compact, round objects (often cleaner masks), whereas lowering it retains irregular colonies but may keep more debris or merged objects. Raises: ValueError: If ``cutoff`` is outside [0, 1]. """ if cutoff < 0 or cutoff > 1: raise ValueError("threshold should be a number between 0 and 1.") self.cutoff = cutoff
def _operate(self, image: Image) -> Image: # Create intial measurement table table = ( pd.DataFrame( regionprops_table( label_image=image.objmap[:], intensity_image=image.gray[:], properties=["label", "area", "perimeter"], ) ) .rename(columns={"label": OBJECT.LABEL}) .set_index(OBJECT.LABEL) ) # Calculate circularity based on Polsby-Popper Score table["circularity"] = (4 * math.pi * table["area"]) / (table["perimeter"] ** 2) passing_objects = table[table["circularity"] > self.cutoff] failed_object_boolean_indices = ~( np.isin( element=image.objmap[:], test_elements=passing_objects.index.to_numpy() ) ) image.objmap[failed_object_boolean_indices] = 0 return image