Source code for phenotypic.enhance._composite_enhance

from __future__ import annotations

from typing import TYPE_CHECKING, Any, List, Literal

import numpy as np
from pydantic import Field, field_validator

if TYPE_CHECKING:
    from phenotypic._core._image import Image

from ..abc_ import ImageEnhancer
from ..sdk_.typing_ import OperationField
from ._gaussian_blur import GaussianBlur
from ._median_filter import MedianFilter

CombineMode = Literal["max", "mean", "min", "median"]


[docs] class CompositeEnhance(ImageEnhancer): """Enhance ``detect_mat`` by combining several enhancers' response maps pixel-wise. Apply two or more enhancers (or preprocessing pipelines ending in an enhancer) to the same plate image and reduce their ``detect_mat`` outputs into a single response map. This ensembles complementary preprocessing strategies: where one enhancer responds strongly to faint colonies and another to sharp edges, ``'max'`` keeps the strongest signal from either. It is the enhancer-side analogue of :class:`CompositeDetector`, which combines binary masks rather than continuous response maps. Each branch reads the *same* input ``detect_mat`` independently (branches do not chain into one another), so the order of ``ops`` does not affect the result for the commutative reductions used here. Best For: - Fusing complementary focus maps (e.g. an edge response and a blob response) so colonies that only one branch emphasises survive. - Averaging several denoisers (``'mean'`` / ``'median'``) to suppress branch-specific artefacts before thresholding. - Consensus preprocessing (``'min'``), where a pixel stays bright only if every branch agrees it is signal. Consider Also: - A single :class:`ImageEnhancer` when one preprocessing step already isolates colonies reliably. - :class:`CompositeDetector` when you want to combine *detections* (binary masks) rather than continuous enhancement maps. - An :class:`ImagePipeline` of enhancers when the steps should be applied *in sequence* rather than combined in parallel. Args: ops: List of :class:`~phenotypic.abc_.ImageEnhancer` or :class:`~phenotypic.ImagePipeline` instances to combine. Pipelines allow per-branch preprocessing before the response is collected. Defaults to ``[GaussianBlur(), MedianFilter()]`` when not specified. ``None`` entries mark unfilled GUI-builder slots and are skipped. mode: Pixel-wise reduction across branch response maps. ``'max'`` keeps the brightest response (union of complementary signals, the default). ``'min'`` keeps the darkest (consensus suppression). ``'mean'`` averages all branches. ``'median'`` takes the per-pixel median (robust to a single outlier branch; most useful with three or more enhancers). Default: ``'max'``. clip: When ``True``, clamp the combined result into ``[0.0, 1.0]`` after reduction. Leave ``False`` (default) when ``detect_mat`` is not unit-normalised, since clipping an arbitrarily-scaled map would distort it. Returns: Image: Input image with ``detect_mat`` set to the combined response map. ``rgb`` and ``gray`` are unchanged. Raises: ValueError: If ``ops`` is empty or contains only ``None`` slots. Examples: Combine two enhancers, keeping the strongest response per pixel: >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.enhance import ( ... CompositeEnhance, ... GaussianBlur, ... SubtractRollingBall, ... ) >>> image = load_synth_yeast_plate() >>> combiner = CompositeEnhance( ... ops=[GaussianBlur(sigma=1.5), SubtractRollingBall()], ... mode="max", ... ) >>> enhanced = combiner.apply(image) >>> bool((image.gray[:] == enhanced.gray[:]).all()) # rgb/gray untouched True Average three branches and clamp the result to ``[0, 1]``: >>> from phenotypic.enhance import MedianFilter, EnhanceLocalContrast >>> combiner = CompositeEnhance( ... ops=[GaussianBlur(), MedianFilter(), EnhanceLocalContrast()], ... mode="mean", ... clip=True, ... ) >>> enhanced = combiner.apply(load_synth_yeast_plate()) >>> float(enhanced.detect_mat[:].max()) <= 1.0 True """ # Each branch may be an ``ImageEnhancer`` or a nested ``ImagePipeline``. # ``OperationField`` keeps the concrete class of each entry across a JSON # round-trip (a bare ``model_dump`` of the union would lose the subclass). # ``| None`` permits an empty list slot that the GUI builder uses to mark # an unfilled enhancer slot in an in-progress pipeline. ops: List[OperationField | None] = Field( default_factory=lambda: [GaussianBlur(), MedianFilter()] ) mode: CombineMode = "max" clip: bool = False @field_validator("ops", mode="before") @classmethod def _default_ops(cls, value: Any) -> Any: """Map an explicit ``None`` onto the default enhancer pair. Mirrors :class:`CompositeDetector`: the ``default_factory`` covers the omitted-argument case, and this validator preserves an explicit ``ops=None`` call by substituting the default pair. """ if value is None: return [GaussianBlur(), MedianFilter()] return value def _operate(self, image: Image) -> Image: """Apply each branch and reduce their ``detect_mat`` maps pixel-wise.""" # Import here to avoid a circular dependency at module load. from phenotypic import ImagePipeline response_maps: list[np.ndarray] = [] for enhancer in self.ops: if enhancer is None: # An unfilled GUI-builder slot; nothing to apply -- skip it. continue if isinstance(enhancer, ImagePipeline): enhanced = enhancer.apply(image, inplace=False, reset=False) else: enhanced = enhancer.apply(image, inplace=False) response_maps.append(np.asarray(enhanced.detect_mat[:], dtype=float)) if not response_maps: raise ValueError( "At least one enhancer must be provided to CompositeEnhance" ) combined = self._combine(response_maps) if self.clip: combined = np.clip(combined, 0.0, 1.0) original = image.detect_mat[:] image.detect_mat[:] = combined.astype(original.dtype, copy=False) return image def _combine(self, response_maps: list[np.ndarray]) -> np.ndarray: """Reduce stacked branch response maps according to ``self.mode``.""" if self.mode == "max": return np.maximum.reduce(response_maps) if self.mode == "min": return np.minimum.reduce(response_maps) stack = np.stack(response_maps, axis=0) if self.mode == "mean": return np.mean(stack, axis=0) # "median" return np.median(stack, axis=0)