Source code for phenotypic.refine._remove_grid_outliers

from __future__ import annotations
from typing import TYPE_CHECKING, Annotated

if TYPE_CHECKING:
    from phenotypic._core._grid_image import GridImage

import numpy as np
from pydantic import AliasChoices, Field

from phenotypic.abc_ import GridObjectRefiner
from phenotypic.measure import MeasureGridLinRegStats
from phenotypic.schema import GRID_LINREG_STATS, GRID
from phenotypic.sdk_.typing_ import TuneSpec


[docs] class RemoveGridOutliers(GridObjectRefiner): """Remove positional outliers from noisy grid rows or columns using IQR-based residual pruning. Fits linear-regression trends to colony centroids along each row and column, identifies lines whose residual coefficient of variance exceeds ``max_coeff_variance``, then removes objects whose residual error exceeds the mean plus ``cutoff_multiplier`` times the IQR within those noisy lines. Rows and columns with low variance are left untouched. Requires a ``GridImage`` with grid metadata already populated. For a comparison of grid refinement approaches, see :doc:`/explanation/refinement_strategies`. Best For: - Plates where most rows and columns are well-aligned but a few contain off-grid detections from condensation, glare, or debris. - Stabilizing grid registration before size or intensity measurement when a subset of row or column positions is noisy. - Batch runs where individual plates occasionally have edge rows with aberrant detections that should not propagate to measurements. Consider Also: - :class:`ReduceSectionsByLine` when the goal is reducing each cell to one detection rather than pruning outliers within lines. - :class:`GridAlignmentRefiner` for full grid-aware filtering using dominant-object-per-cell selection without variance-based gating. - :class:`GridOversizedObjectRemover` when the problem is objects spanning multiple grid sections rather than positional outliers. Args: axis: Grid axis to analyze. ``None`` processes both rows and columns; ``0`` restricts to rows; ``1`` restricts to columns. Restricting the axis speeds processing when the problem direction is known. Default: ``None``. cutoff_multiplier: IQR-based residual cutoff multiplier. Objects whose residual exceeds ``mean + cutoff_multiplier × IQR`` within a noisy line are removed. Lower values prune more aggressively; higher values are conservative. Also accepted as ``stddev_multiplier`` for backwards compatibility. Typical range: 1.0--3.0. Default: 1.5. max_coeff_variance: Maximum coefficient of variance (std / mean of residuals) allowed for a row or column before it is considered noisy and subjected to outlier pruning. Lines below this threshold are left untouched. Typical range: 1--5. Default: 1. Returns: Image: Input image with ``objmap`` and ``objmask`` updated to exclude positional outliers from noisy grid rows and columns. ``rgb``, ``gray``, and ``detect_mat`` are unchanged. See Also: :doc:`/how_to/notebooks/refine_noisy_boundaries` for grid-based outlier removal workflows. :doc:`/explanation/refinement_strategies` for a comparison of grid refinement approaches. """ #: Axis selection — ``None`` for both, ``0`` for row, ``1`` for column. axis: Annotated[int | None, TuneSpec(tunable=False)] = None #: Robust residual cutoff multiplier. The public constructor keyword is #: the legacy ``stddev_multiplier``; the attribute the algorithm reads is #: ``cutoff_multiplier`` (the pre-migration ``__init__`` renamed the #: parameter on assignment). ``AliasChoices`` accepts both the legacy #: keyword and the field name so existing call sites and JSON #: round-trips keep working. cutoff_multiplier: Annotated[float, TuneSpec(1.0, 3.0)] = Field( default=1.5, validation_alias=AliasChoices("stddev_multiplier", "cutoff_multiplier"), description=( "IQR-based cutoff multiplier for outlier removal. Lower " "values prune more aggressively; higher values are " "conservative. Typical range: 1.0--3.0. Default: 1.5." ), ) max_coeff_variance: Annotated[int, TuneSpec(1, 5)] = 1 def _operate(self, image: GridImage) -> GridImage: """Identify and remove residual outliers per noisy row/column. Args: image (GridImage): Grid image with object map and grid metadata. Returns: GridImage: Modified grid image with outlier objects removed. Raises: ValueError: If parameters are misconfigured in a way that prevents computation (propagated from measurement utilities). """ # Generate cached version of grid_info linreg_stat_extractor = MeasureGridLinRegStats() grid_info = linreg_stat_extractor.measure(image) # Create container to hold the id of objects to be removed outlier_obj_ids = [] # Row-wise residual outlier discovery if self.axis is None or self.axis == 0: # Calculate the coefficient of variance (std/mean) # Collect the standard deviation row_variance = grid_info.groupby(str(GRID.ROW_NUM))[ str(GRID_LINREG_STATS.RESIDUAL_ERR) ].std() # Divide standard deviation by mean row_variance = ( row_variance / grid_info.groupby(str(GRID.ROW_NUM))[ str(GRID_LINREG_STATS.RESIDUAL_ERR) ].mean() ) over_limit_row_variance = row_variance.loc[ row_variance > self.max_coeff_variance ] # Collect outlier objects in the nrows with a variance over the maximum for row_idx in over_limit_row_variance.index: row_err = grid_info.loc[ grid_info.loc[:, str(GRID.ROW_NUM)] == row_idx, str(GRID_LINREG_STATS.RESIDUAL_ERR), ] row_err_mean = row_err.mean() row_q3, row_q1 = row_err.quantile([0.75, 0.25]) row_iqr = row_q3 - row_q1 # row_stddev = row_err.std() # upper_row_cutoff = row_err_mean + row_stddev * self.cutoff_multiplier upper_row_cutoff = row_err_mean + row_iqr * self.cutoff_multiplier outlier_obj_ids += row_err.loc[ row_err >= upper_row_cutoff ].index.tolist() # Column-wise residual outlier discovery if self.axis is None or self.axis == 1: # Calculate the coefficient of variance (std/mean) # Collect the standard deviation col_variance = grid_info.groupby(str(GRID.COL_NUM))[ str(GRID_LINREG_STATS.RESIDUAL_ERR) ].std() # Divide standard deviation by mean col_variance = ( col_variance / grid_info.groupby(str(GRID.COL_NUM))[ str(GRID_LINREG_STATS.RESIDUAL_ERR) ].mean() ) over_limit_col_variance = col_variance.loc[ col_variance > self.max_coeff_variance ] # Collect outlier objects in the columns with a variance over the maximum for col_idx in over_limit_col_variance.index: col_err = grid_info.loc[ grid_info.loc[:, str(GRID.COL_NUM)] == col_idx, str(GRID_LINREG_STATS.RESIDUAL_ERR), ] col_err_mean = col_err.mean() col_q3, col_q1 = col_err.quantile([0.75, 0.25]) col_iqr = col_q3 - col_q1 # col_stddev = col_err.std() upper_col_cutoff = col_err_mean + col_iqr * self.cutoff_multiplier outlier_obj_ids += col_err.loc[ col_err >= upper_col_cutoff ].index.tolist() # Remove objects from obj map image.objmap[np.isin(image.objmap[:], outlier_obj_ids)] = 0 return image