Source code for phenotypic.enhance._contrast_sigmoid

from __future__ import annotations

from typing import TYPE_CHECKING, Annotated

if TYPE_CHECKING:
    from phenotypic._core._image import Image

import numpy as np
from skimage.exposure import adjust_sigmoid

from ..abc_ import ContrastAdjustment
from ..sdk_.mixin import InputLayerMixin, NormalizedOutputMixin
from ..sdk_.typing_ import TuneSpec


[docs] class ContrastSigmoid(InputLayerMixin, NormalizedOutputMixin, ContrastAdjustment): """Apply a sigmoid intensity curve that steepens contrast about a chosen cutoff. Computes ``1 / (1 + exp(gain * (cutoff - I)))``, an S-shaped curve centred on ``cutoff``. Pixels below the cutoff are pushed toward 0 and pixels above it toward 1, while the transition stays continuous. Raising ``gain`` steepens the S until it approaches a hard threshold. Best For: - Pushing a soft, gradual colony/agar boundary toward a binary decision before a global threshold such as Otsu or Triangle, without committing to a hard cut. - Suppressing low-amplitude agar texture while leaving colony interiors saturated. - Plates where the colony/background split sits at a known intensity that ``cutoff`` can be set to directly. Consider Also: - :class:`ContrastGamma` when the whole tonal range should be reweighted rather than steepened about one intensity. - :class:`ContrastStretching` when the histogram is merely narrow and a linear remap suffices. Args: cutoff: Intensity about which the sigmoid is centred, in [0, 1]. Pixels below it are pushed toward 0, above it toward 1. Set near the agar/colony boundary intensity. Default: 0.5. gain: Steepness of the sigmoid. Larger values approach a hard threshold; smaller values blend gradually. Typical range: 5--15. Default: 10.0. Unlike :class:`ContrastGamma`, this ``gain`` survives ``norm="rescale"`` because it reshapes the curve rather than scaling its output. inv: When ``True``, invert the sigmoid so bright regions are suppressed. Default: ``False``. norm: Output range policy. ``"clip"`` (default) saturates values outside [0, 1]; ``"rescale"`` remaps the full observed range onto [0, 1]; ``None`` passes values through untouched. input_layer: Source layer. ``"detect_mat"`` (default) applies the curve to the 2-D detection matrix. ``"rgb"`` applies it to all three colour channels, then collapses the result to 2-D through the image's own ``detect_mode``. Because the curve is non-linear, the two routes generally differ -- except under a ``detect_mode`` that is a per-pixel selection (``"red"``/``"green"``/``"blue"``/``"MinRGB"``/``"HsvV"``), which commutes with any monotonically increasing curve and so yields an identical result. Default: ``"detect_mat"``. Returns: Image: Input image with ``detect_mat`` sigmoid-corrected. ``rgb`` and ``gray`` are unchanged. With ``input_layer="rgb"``, any enhancement a prior operation wrote to ``detect_mat`` is discarded, as with :class:`SetDetectMode`. Examples: Steepen the colony/agar transition about the plate's mean intensity: >>> from phenotypic.data import load_synth_yeast_plate >>> from phenotypic.enhance import ContrastSigmoid >>> plate = load_synth_yeast_plate() >>> cutoff = float(plate.detect_mat[:].mean()) >>> enhanced = ContrastSigmoid(cutoff=cutoff, gain=10.0).apply(plate) >>> float(enhanced.detect_mat[:].std()) > float(plate.detect_mat[:].std()) True """ cutoff: Annotated[float, TuneSpec(0.0, 1.0)] = 0.5 gain: Annotated[float, TuneSpec(1.0, 20.0)] = 10.0 inv: bool = False def _operate(self, image: Image) -> Image: src = self._guard_input_range(self._read_input_layer(image)) adjusted = adjust_sigmoid(src, cutoff=self.cutoff, gain=self.gain, inv=self.inv) collapsed = self._project_to_detect_mat(image, adjusted) image.detect_mat[:] = self._apply_norm(collapsed).astype(np.float32) return image