Source code for phenotypic.sdk_.mixin._input_layer_mixin
"""Mixin letting an operation read either ``detect_mat`` or the pristine ``rgb`` layer."""
from __future__ import annotations
import math
from typing import TYPE_CHECKING, Any
import numpy as np
from pydantic import BaseModel
from skimage.exposure import rescale_intensity
from phenotypic.sdk_.funcs_ import normalize_rgb_bitdepth
from phenotypic.sdk_.typing_ import InputLayer
if TYPE_CHECKING:
from phenotypic._core._image import Image
[docs]
class InputLayerMixin(BaseModel):
"""Adds an ``input_layer`` field selecting the operation's source array.
Pointwise intensity curves are non-linear, so applying one to the three RGB
channels and *then* collapsing to a detection matrix gives a different — often
better — colony/background separation than collapsing first. This mixin exposes
that choice without changing the output contract: the only layer an enhancer
ever writes is still ``detect_mat``.
When ``input_layer="rgb"`` the 3-D result is collapsed back to 2-D by projecting
it through the image's own ``detect_mode``, so an upstream
``SetDetectMode(mode="MinRGB")`` is honoured.
The field is **appended** to the end of the subclass's field order. When stacked
with :class:`NormalizedOutputMixin`, list this mixin first; the resulting order
is ``[…op params…, norm, input_layer]``.
Note:
Reading ``rgb`` discards any enhancement a prior operation wrote to
``detect_mat`` — the same behaviour as ``SetDetectMode``. This is documented,
not enforced.
"""
input_layer: InputLayer = "detect_mat"
[docs]
@classmethod
def __pydantic_init_subclass__(cls, **kwargs: Any) -> None:
"""Move ``input_layer`` to the end of the subclass's field order."""
super().__pydantic_init_subclass__(**kwargs)
fields = cls.__pydantic_fields__
if "input_layer" in fields and list(fields)[-1] != "input_layer":
fields["input_layer"] = fields.pop("input_layer")
cls.model_rebuild(force=True)
def _read_input_layer(self, image: "Image") -> np.ndarray:
"""Return the source array for this operation.
Both branches return a **read-only** array. Callers must not mutate the
result in place; build a new array instead. The two layers would otherwise
disagree — ``detect_mat`` hands back a non-writeable view onto the image's
own buffer, while the ``rgb`` branch builds a fresh array — and an operation
doing in-place work would succeed under one ``input_layer`` and raise under
the other.
Returns:
The 2-D read-only ``detect_mat`` view, or a 3-D read-only float32 RGB
array normalized to [0, 1].
Raises:
NoArrayError: If ``input_layer="rgb"`` on a grayscale-only image.
"""
if self.input_layer == "rgb":
# ``image.rgb[:]`` raises NoArrayError on a grayscale-only image, where
# ``rgb.normed()`` would silently hand back a degenerate ``(0, 3)`` array.
# Integer RGB takes the allocation-efficient float32 path. Float RGB is
# already a supported image representation, including normalized [0, 1]
# arrays, and needs the shared range-aware normalizer instead of
# ``rgb.vmax()`` (which is defined only for integer dtypes).
raw = image.rgb[:]
if np.issubdtype(raw.dtype, np.integer):
arr = np.asarray(raw, dtype=np.float32)
np.divide(
arr,
np.float32(np.iinfo(raw.dtype).max),
out=arr,
)
else:
arr = normalize_rgb_bitdepth(raw)
arr.flags.writeable = False
return arr
return image.detect_mat[:]
def _project_to_detect_mat(self, image: "Image", arr: np.ndarray) -> np.ndarray:
"""Collapse a 3-D array to 2-D via the image's ``detect_mode``.
A 2-D array is returned unchanged (identity, not a copy). It is read-only,
so a caller cannot corrupt the image's buffer through the alias.
Raises:
ValueError: If *arr* is neither 2-D nor a 3-channel 3-D array.
"""
if arr.ndim == 2:
return arr
if arr.ndim != 3 or arr.shape[2] != 3:
raise ValueError(
f"Expected a 2-D detect_mat or a 3-D (rows, cols, 3) RGB array, "
f"got shape {arr.shape}."
)
from phenotypic._core._image_parts.detection_modes import get_detection_mode
mode = get_detection_mode(image.detect_mode)
return mode.compute_from_rgb(arr, image=image)
def _guard_input_range(self, arr: np.ndarray) -> np.ndarray:
"""Rescale *arr* into [0, 1] when it strays outside, else return it unchanged.
skimage's ``adjust_gamma`` / ``adjust_log`` / ``adjust_sigmoid`` raise
``ValueError`` on negative input, which a signed filter such as
``FocusEdgeLaplace`` produces.
Skipped entirely when ``norm is None`` so a deliberately non-normalized
(e.g. GAT-stabilized) signal is left alone. An operation that mixes in
:class:`InputLayerMixin` *without* :class:`NormalizedOutputMixin` has no
``norm`` field; it is treated as ``"clip"``, i.e. the guard is active. That
default matches ``NormalizedOutputMixin``'s own, so stacking the mixins does
not change input-side behaviour.
Raises:
ValueError: If *arr* contains NaN or infinity. ``min()``/``max()``
propagate NaN, and both ``nan < 0`` and ``nan > 1`` are ``False``,
so a NaN would otherwise slip past this guard silently and surface
as skimage's opaque non-negative-values error. ``rescale_intensity``
would additionally smear a single NaN across the whole array.
"""
if getattr(self, "norm", "clip") is None:
return arr
# Two O(n) reductions, no temporary: min/max propagate NaN, and +/-inf
# surface in exactly one of them.
low, high = float(arr.min()), float(arr.max())
if not (math.isfinite(low) and math.isfinite(high)):
raise ValueError(
f"{type(self).__name__}: the {self.input_layer!r} layer contains "
f"non-finite values (min={low}, max={high}). Range-guarding it is "
f"undefined. Fix the upstream operation, or set norm=None to pass "
f"the array through untouched."
)
if low < 0.0 or high > 1.0:
return rescale_intensity(arr, out_range=(0.0, 1.0))
return arr