Source code for phenotypic.refine._remove_by_feature

from __future__ import annotations

from typing import TYPE_CHECKING, Annotated, Any

if TYPE_CHECKING:
    from phenotypic._core._image import Image

import numpy as np
from pydantic import field_validator, model_validator
from typing_extensions import Self

from ..abc_ import ObjectRefiner
from ..sdk_.typing_ import TuneSpec
from phenotypic.schema import OBJECT


[docs] class RemoveByFeature(ObjectRefiner): """Remove objects whose measured feature value falls outside a ``[min, max]`` band. A generic, measurer-driven cleanup step: name any :class:`~phenotypic.abc_.MeasureFeatures` subclass, pick one of the values it produces, and discard every object whose value is below ``min_value`` or above ``max_value``. This subsumes the purpose-built filters (:class:`SmallObjectRemover` for area, :class:`RemoveLowCircularity` for shape) into a single configurable operation that works for *any* feature the measurement layer exposes. The named measurer is instantiated, run against the image, and the resulting per-object table is filtered: objects inside the inclusive band survive, objects outside it (or with a ``NaN`` value) are zeroed from ``objmap``. For an overview of refinement strategies, see :doc:`/explanation/refinement_strategies`. Best For: - Pruning by a phenotype that has no dedicated refiner (intensity, colour, texture, eccentricity, …) without writing a new operation. - Two-sided gating, e.g. keep colonies whose area sits within an expected window and drop both specks and merged blobs in one step. - Reusing the exact measurement definitions the pipeline reports, so the filter and the measurement column stay in lock-step. Consider Also: - :class:`SmallObjectRemover` / :class:`RemoveLowCircularity` for the common size/shape cases where a self-contained, single-parameter operation is clearer. Args: feature: Name of a :class:`~phenotypic.abc_.MeasureFeatures` subclass to run, e.g. ``"MeasureSize"``. Resolved from the public ``phenotypic`` namespace. ``None`` (the default) makes the operation a no-op. value: Name of the measured value to filter on. Accepts either the category-prefixed column emitted by the measurer (``"Size_Area"``) or the bare label (``"Area"``); the bare form is resolved against this measurer's columns only, so there is no cross-feature ambiguity. ``None`` (the default) makes the operation a no-op. min_value: Inclusive lower bound. Objects with ``value < min_value`` are removed. ``None`` (the default) leaves the lower side unbounded. max_value: Inclusive upper bound. Objects with ``value > max_value`` are removed. ``None`` (the default) leaves the upper side unbounded. measure_kwargs: Optional keyword arguments forwarded to the measurer's constructor (e.g. ``{"include_XYZ": False}`` for ``MeasureColor``). Carried as a dict because operations are keyword-only pydantic models that forbid loose extra fields. ``None`` (the default) constructs the measurer with its own defaults. Returns: Image: Input image with ``objmap`` and ``objmask`` updated to exclude objects whose ``value`` is outside ``[min_value, max_value]``. ``rgb``, ``gray``, and ``detect_mat`` are unchanged. When ``feature`` or ``value`` is ``None``, or both bounds are ``None``, the image is returned unchanged. Raises: ValueError: If ``feature`` does not name a ``MeasureFeatures`` subclass, if ``value`` matches no (or ambiguously several) measured columns, or if ``min_value > max_value``. See Also: :doc:`/explanation/refinement_strategies` for choosing the right refinement sequence. Examples: Keep only colonies whose area is at least 50 px, dropping smaller debris: >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.refine import RemoveByFeature >>> plate = load_synth_yeast_plate() >>> before = plate.num_objects >>> remover = RemoveByFeature(feature="MeasureSize", value="Size_Area", min_value=50) >>> result = remover.apply(plate) >>> result.num_objects <= before True Constructed with defaults it is an inert no-op, so it is always safe to place in a pipeline before its parameters are configured: >>> RemoveByFeature().apply(load_synth_yeast_plate()).num_objects > 0 True """ feature: str | None = None value: str | None = None # min/max ARE worth tuning, but their meaningful range depends entirely on the # chosen feature/value — there is no universal search window. A bare TuneSpec() # (tunable, no low/high) marks them as intended knobs while letting auto-search # surface them as range-less rather than fabricate a window; the concrete range # is supplied per-run in the tune spec. min_value: Annotated[float | None, TuneSpec()] = None max_value: Annotated[float | None, TuneSpec()] = None measure_kwargs: dict[str, Any] | None = None @field_validator("feature") @classmethod def _validate_feature(cls, feature: str | None) -> str | None: """Require ``feature`` to name a concrete ``MeasureFeatures`` subclass.""" if feature is None: return None if cls._resolve_measurer(feature) is None: raise ValueError( f"'{feature}' does not name a MeasureFeatures subclass in the " f"phenotypic namespace. Ensure it is exported from its " f"submodule's __init__.py." ) return feature @model_validator(mode="after") def _validate_bounds(self) -> Self: """Reject an inverted ``[min_value, max_value]`` band.""" if ( self.min_value is not None and self.max_value is not None and self.min_value > self.max_value ): raise ValueError( f"min_value ({self.min_value}) must not exceed " f"max_value ({self.max_value})." ) return self @staticmethod def _resolve_measurer(feature: str) -> type | None: """Resolve ``feature`` to a concrete ``MeasureFeatures`` subclass, or None.""" from phenotypic.abc_ import MeasureFeatures from phenotypic._core._pipeline_parts._serializable_pipeline import ( SerializablePipeline, ) op_class = SerializablePipeline._find_class_in_phenotypic(feature) if ( isinstance(op_class, type) and issubclass(op_class, MeasureFeatures) ): return op_class return None def _resolve_column(self, columns: list[str]) -> str: """Match ``self.value`` to one measured column (exact, then bare-label).""" candidates = [col for col in columns if col != OBJECT.LABEL] if self.value in candidates: return self.value # type: ignore[return-value] suffix_matches = [ col for col in candidates if col.split("_", 1)[-1] == self.value ] if len(suffix_matches) == 1: return suffix_matches[0] if len(suffix_matches) > 1: raise ValueError( f"'{self.value}' ambiguously matches columns {suffix_matches}; " f"pass the fully-prefixed name." ) raise ValueError( f"'{self.value}' is not a measured value of {self.feature}. " f"Available: {candidates}." ) def _operate(self, image: Image) -> Image: if ( self.feature is None or self.value is None or (self.min_value is None and self.max_value is None) ): return image measurer_cls = self._resolve_measurer(self.feature) if measurer_cls is None: # pragma: no cover - guarded by the validator raise ValueError(f"'{self.feature}' is not a MeasureFeatures subclass.") measurer = measurer_cls(**(self.measure_kwargs or {})) table = measurer.measure(image) column = self._resolve_column(list(table.columns)) values = table[column].to_numpy() labels = table[OBJECT.LABEL].to_numpy() keep = np.ones(len(values), dtype=bool) if self.min_value is not None: keep &= values >= self.min_value if self.max_value is not None: keep &= values <= self.max_value failing_labels = labels[~keep] if failing_labels.size: image.objmap[np.isin(image.objmap[:], failing_labels)] = 0 return image