Source code for phenotypic.refine._remove_low_circularity
from __future__ import annotations
from typing import TYPE_CHECKING, Annotated
if TYPE_CHECKING:
from phenotypic._core._image import Image
import pandas as pd
import numpy as np
from pydantic import field_validator
from skimage.measure import regionprops_table
import math
from ..abc_ import ObjectRefiner
from ..sdk_.typing_ import TuneSpec
from phenotypic.schema import OBJECT
[docs]
class RemoveLowCircularity(ObjectRefiner):
"""Remove objects whose Polsby-Popper circularity score falls below a threshold.
Computes ``4π × area / perimeter²`` for each labeled object and
discards those below ``cutoff``. A perfect circle scores 1.0; elongated
or jagged shapes score lower. Well-formed round colonies are retained
while scratches, merged blobs, and irregular segmentation debris are
removed.
For an overview of morphological refinement strategies, see
:doc:`/explanation/refinement_strategies`.
Best For:
- Post-threshold cleanup to exclude elongated scratches, merged
colony blobs, or agar-edge artefacts before size or shape
measurement.
- High-throughput grid assays where all genuine colonies are
expected to be round and irregular detections indicate artefacts.
- Plates with round yeast or bacterial colonies where any
non-circular detection is reliably spurious.
Consider Also:
- :class:`SmallObjectRemover` when artefacts are better
distinguished by area than by shape, or when colony morphology
is legitimately non-circular.
- :class:`RemoveBorderObjects` when irregular detections cluster
near the plate edge because of partial cropping.
- :class:`MaskOpening` for smoothing jagged boundaries before
circularity filtering so that genuine colonies are not penalised
by pixelation artefacts in the perimeter measurement.
Args:
cutoff: Minimum Polsby-Popper circularity score in ``[0, 1]``
required to retain an object. A perfect circle has a score of
1.0; elongated or irregular shapes score lower. The default
0.785 (π/4) is the area ratio of a circle inscribed in its
bounding square and provides a reasonable boundary between
compact colonies and elongated artefacts. Higher values enforce
stricter roundness; lower values tolerate irregular morphology.
Typical range: 0.5--0.9. Default: 0.785.
Returns:
Image: Input image with ``objmap`` and ``objmask`` updated to
exclude all objects whose circularity is at or below ``cutoff``.
``rgb``, ``gray``, and ``detect_mat`` are unchanged.
Raises:
ValueError: If ``cutoff`` is outside ``[0, 1]``.
See Also:
:doc:`/how_to/notebooks/refine_noisy_boundaries` for shape-based
cleanup workflows on real plate images.
:doc:`/explanation/refinement_strategies` for a comparison of
morphological refinement methods.
"""
cutoff: Annotated[float, TuneSpec(0.5, 0.9)] = 0.785
@field_validator("cutoff")
@classmethod
def _validate_cutoff(cls, cutoff: float) -> float:
"""Reject a ``cutoff`` outside ``[0, 1]``.
Reproduces the pre-migration ``__init__`` guard verbatim.
"""
if cutoff < 0 or cutoff > 1:
raise ValueError("threshold should be a number between 0 and 1.")
return 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