from __future__ import annotations
from typing import (
TYPE_CHECKING,
Annotated,
Any,
Callable,
Dict,
List,
Literal,
Optional,
Tuple,
)
import numpy as np
from pydantic import (
AfterValidator,
BeforeValidator,
PlainSerializer,
WithJsonSchema,
)
if TYPE_CHECKING:
from phenotypic.abc_ import ImageOperation
FootprintShape = Literal["disk", "square", "diamond"]
DetectMode = Literal["gray", "red", "green", "blue", "MinRGB", "LabL", "LabA", "LabB", "HsvS", "HsvV", "InvS"]
#: Image layer a process-mode CLI run exports. A closed
#: subset of the layers exposed as Image accessors; ``rgb``/``gray``/
#: ``detect_mat`` save as TIFF, ``objmap`` as a raw-label PNG.
ProcessOnlyLayer = Literal["rgb", "gray", "detect_mat", "objmap"]
#: Image layer a GpuDetector consumes as model input. Single-channel layers
#: (gray/detect_mat) are stacked to (H, W, 3) by GpuDetector.preprocess.
GpuInputLayer = Literal["rgb", "gray", "detect_mat"]
#: Object output a GpuDetector produces. "instance" -> labeled objmap;
#: "semantic" -> binary objmask (auto-labels into objmap, like a threshold detector).
GpuOutputKind = Literal["instance", "semantic"]
#: DINO backbone generation for DinoSam2Detector. 2 = DINOv2 (Apache, ungated,
#: default); 3 = DINOv3 (gated, opt-in — routes through require_license_acceptance).
#: Type-only closed set (no Enum / documentation surface needed).
DinoVersion = Literal[2, 3]
#: DINO backbone size for DinoSam2Detector. Maps with DinoVersion to the HF
#: model id (e.g. (2, "base") -> "facebook/dinov2-base").
DinoSize = Literal["small", "base", "large"]
#: Public top-level CLI execution mode. ``full`` performs the normal
#: apply-and-measure run, ``measure`` reruns measurement from existing HDFs,
#: ``recompile`` refreshes aggregate outputs from an existing output root, and
#: ``process`` performs the apply-only single-layer export selected by
#: ``--layer``.
CliMode = Literal["full", "measure", "recompile", "process"]
GridSearchSaveData = List[
Literal["rgb", "gray", "detect_mat", "objmap", "objmask", "map2rgb"]
]
GridSearchConfig = List[Tuple["ImageOperation", Dict[str, List[Any]]]]
# ---------------------------------------------------------------------------
# CLI / GUI closed value sets — single source of truth, imported by callers
# rather than re-spelled. Pair with phenotypic.sdk_.constants_.IMAGE_TYPES
# (paired Enum + Literal alignment is asserted in tests/unit/sdk_/test_io_constants.py).
# ---------------------------------------------------------------------------
#: Forward-run / recompile execution backend. Bare-string carrier for
#: serialization (``progress/job_metadata.json``); callers convert to/from the
#: dataclass field of the same name on ``ProcessingState`` / ``ExecutionResults``.
ExecutionMode = Literal["local", "slurm"]
#: String form of the ``IMAGE_TYPES`` Enum's ``BASE`` and ``GRID`` members,
#: which are the only two image-type values that cross CLI / GUI boundary
#: code today. Other ``IMAGE_TYPES`` members (CROP / OBJECT / GRID_SECTION)
#: are internal to the core library and don't need a Literal partner yet.
ImageTypeName = Literal["Image", "GridImage"]
#: Per-image processing-events log statuses. Used by ``_cli_update_state``
#: and consumed by the dashboard generator.
ProcessingStatus = Literal["started", "completed", "failed"]
#: ``--recompile-task`` flag values for the SLURM recompile worker. Mirrors
#: the bare-string ``TASK_*`` constants in ``_cli_recompile_slurm_scripts.py``.
RecompileTaskType = Literal["measurements", "overlay", "finalize"]
#: ``--checkpoint-type`` flag values for the SLURM sentinel handler.
CheckpointType = Literal["manifest", "finalize"]
#: Tag attached to each row of ``progress/failures.jsonl`` distinguishing
#: Python-side exceptions from SLURM sbatch failures.
FailureSource = Literal["python", "slurm"]
#: The single-objective composite blend selector — the serialized value of
#: ``CompositeScorer.blend``. ``"tchebycheff"`` (default) is conjunctive
#: (worst-axis-dominant, augmented Tchebycheff over per-child cost);
#: ``"weighted_mean"`` is the compensatory opt-out. No Enum partner is needed —
#: this is a serialized field value with no separate documentation surface
#: (mirrors ``DetectMode`` / ``ExecutionMode``). The geometric-mean-of-cost
#: blend is intentionally NOT offered (it inverts the conjunctive property).
CompositeBlend = Literal["tchebycheff", "weighted_mean"]
# ---------------------------------------------------------------------------
# Pydantic-friendly array field — reusable annotated type for operation
# parameters that carry a raw ``np.ndarray`` (kernels, footprints, masks,
# coordinate grids). pydantic cannot validate or serialize a bare
# ``np.ndarray``, so this bundles the three pieces it needs:
# - ``BeforeValidator`` coerces list / nested-list input to an ndarray,
# - ``PlainSerializer`` emits a JSON-native nested list on ``model_dump``,
# - ``WithJsonSchema`` supplies an "array" entry for ``model_json_schema``.
# Host models must still set ``model_config`` with
# ``arbitrary_types_allowed=True`` because the underlying field type is
# ``np.ndarray`` (an arbitrary, non-pydantic type).
# ---------------------------------------------------------------------------
def _coerce_to_ndarray(value: Any) -> np.ndarray:
"""Coerce list / nested-list / ndarray input to an ``np.ndarray``.
Args:
value: A ``list`` (possibly nested), an existing ``np.ndarray``,
or any other array-like accepted by ``np.asarray``.
Returns:
np.ndarray: ``value`` itself if already an ndarray, otherwise the
result of ``np.asarray(value)``.
"""
if isinstance(value, np.ndarray):
return value
return np.asarray(value)
def _ndarray_to_list(value: np.ndarray) -> list:
"""Serialize an ``np.ndarray`` to a JSON-native nested list.
Args:
value: The array to serialize.
Returns:
list: ``value.tolist()`` — a nested list of Python scalars.
"""
return value.tolist()
#: Annotated ``np.ndarray`` usable as a pydantic field annotation.
#:
#: Accepts a ``list`` / nested list or an ``np.ndarray`` as input,
#: serializes to a JSON-native nested list, and reports an ``"array"``
#: JSON schema. The host model **must** declare ``model_config`` with
#: ``arbitrary_types_allowed=True`` (the field type is the arbitrary,
#: non-pydantic ``np.ndarray``).
#:
#: Example:
#: >>> import numpy as np
#: >>> from pydantic import BaseModel, ConfigDict
#: >>> from phenotypic.sdk_.typing_ import NdArrayField
#: >>> class KernelOp(BaseModel):
#: ... model_config = ConfigDict(arbitrary_types_allowed=True)
#: ... kernel: NdArrayField
#: >>> op = KernelOp(kernel=[[1, 0], [0, 1]])
#: >>> isinstance(op.kernel, np.ndarray)
#: True
#: >>> op.model_dump(mode="json")["kernel"]
#: [[1, 0], [0, 1]]
#: >>> KernelOp.model_json_schema()["properties"]["kernel"]["type"]
#: 'array'
NdArrayField = Annotated[
np.ndarray,
BeforeValidator(_coerce_to_ndarray),
PlainSerializer(_ndarray_to_list, return_type=list),
WithJsonSchema({"type": "array", "items": {}}),
]
# ---------------------------------------------------------------------------
# Pydantic-friendly operation field — reusable annotated type for operation
# parameters that hold *another* operation (or a nested pipeline) whose
# concrete class must survive a JSON round-trip.
#
# A plain ``model_dump`` of a field typed ``ObjectDetector | ImagePipeline``
# would dump only the *base-class* fields, silently losing the concrete
# subclass identity (an ``OtsuDetector`` would dump as an empty
# ``ImageOperation``). ``OperationField`` bundles:
# - a ``PlainSerializer`` that tags each value with its class so the
# concrete type can be rebuilt — ``{"class", "params"}`` for an
# operation, ``{"__type__": "pipeline", "config": ...}`` for a pipeline,
# - a ``BeforeValidator`` that reconstructs the operation/pipeline from
# that tagged dict via the ``phenotypic`` class registry, while passing
# an already-live operation instance straight through.
#
# Used by ``CompositeDetector.detectors`` and
# ``FilamentousFungiDetector.inoculum_detector``. The serialized shape is
# byte-compatible with what ``SerializablePipeline._serialize_*`` emits, so
# the two code paths agree and nested operations round-trip losslessly.
#
# Host models must declare ``arbitrary_types_allowed=True`` (inherited from
# ``BaseOperation``) because the underlying field type is an arbitrary
# (non-pydantic-by-this-annotation) operation class.
# ---------------------------------------------------------------------------
def _serialize_operation_value(value: Any) -> Any:
"""Serialize one operation/pipeline value to a class-tagged dict.
Args:
value: An ``ImageOperation`` instance or an ``ImagePipeline``
(which is itself an ``ImageOperation``).
Returns:
A JSON-native dict carrying the concrete class identity:
``{"__type__": "pipeline", "config": {...}}`` for a pipeline,
otherwise ``{"class": <name>, "params": {...}}`` for an operation.
Any other input is returned unchanged (pydantic reports the
type error).
"""
# Lazy import: the serializer module imports operation classes, so a
# top-level import here would create a cycle through ``sdk_``.
from phenotypic._core._pipeline_parts._serializable_pipeline import (
SerializablePipeline,
)
if isinstance(value, SerializablePipeline):
return {
"__type__": "pipeline",
"config": SerializablePipeline._serialize_pipeline_config(value),
}
if hasattr(value, "model_dump"):
return {
"class": type(value).__name__,
"params": value.model_dump(mode="json"),
}
return value
def _deserialize_operation_value(value: Any) -> Any:
"""Reconstruct one operation/pipeline value from a class-tagged dict.
Args:
value: Either an already-live operation/pipeline instance (passed
straight through), or a class-tagged dict produced by
:func:`_serialize_operation_value`.
Returns:
The reconstructed ``ImageOperation`` / ``ImagePipeline`` instance,
or ``value`` unchanged when it is not a recognised tagged dict.
Raises:
AttributeError: If a tagged dict names a class that cannot be
resolved in the ``phenotypic`` namespace.
"""
from phenotypic._core._pipeline_parts._serializable_pipeline import (
SerializablePipeline,
)
# Already a live operation/pipeline — nothing to reconstruct.
if not isinstance(value, dict):
return value
# Nested pipeline entry.
if value.get("__type__") == "pipeline":
return SerializablePipeline._deserialize_pipeline_config(
value["config"]
)
# Plain operation entry — ``{"class": ..., "params": {...}}``.
if "class" in value:
cls = SerializablePipeline._find_class_in_phenotypic(value["class"])
if cls is None:
raise AttributeError(
f"Class '{value['class']}' not found in phenotypic "
f"namespace. Make sure it's properly imported in "
f"phenotypic.__init__.py"
)
return cls.model_validate(value.get("params", {}) or {})
return value
def _make_require_value(base: "type | Callable[[], type]"):
"""Build an ``AfterValidator`` guard asserting a value is a ``base`` instance.
``base`` may be a concrete type or a zero-arg callable that returns the
type (resolved lazily, so ``OperationField`` can name ``BaseOperation``
without importing it at ``sdk_`` load time — avoiding the import cycle).
Args:
base: The class the value must be an instance of, or a zero-arg
callable returning that class (resolved on first validation).
Returns:
A validator ``_require(value)`` that returns ``value`` unchanged when
it is a ``base`` instance, and raises ``ValueError`` otherwise. The
error is a ``ValueError`` (not ``TypeError``) so pydantic wraps it
into a :class:`pydantic.ValidationError`.
"""
def _require(value: Any) -> Any:
resolved = base if isinstance(base, type) else base()
if not isinstance(value, resolved):
raise ValueError(
f"expected an instance of {resolved.__name__}, got "
f"{type(value).__name__}"
)
return value
return _require
[docs]
def polymorphic_field(base: "type | Callable[[], type]", *, marker: Any = None):
"""A pydantic field for a polymorphic model subtree.
The concrete subclass survives a JSON round-trip via the ``phenotypic``
class registry: the field serializes to ``{"class": <name>, "params":
{...}}`` (or the pipeline-tagged form for an ``ImagePipeline``) and
reconstructs the concrete subclass on load.
Args:
base: Constrains the accepted/validated type — a class or a zero-arg
callable returning the class (the lazy form avoids import cycles,
e.g. ``OperationField`` naming ``BaseOperation``).
marker: Optional sentinel attached to the ``Annotated`` chain (e.g.
the GUI's :class:`_OperationFieldMarker`) so introspecting tools
can recognise the field despite the ``Any`` core erasure.
Returns:
An ``Annotated`` type usable as a pydantic field annotation. Host
models must set ``model_config`` with ``arbitrary_types_allowed=True``.
"""
core = Annotated[
Any,
BeforeValidator(_deserialize_operation_value),
AfterValidator(_make_require_value(base)),
PlainSerializer(_serialize_operation_value),
]
if marker is None:
return core
# Annotated flattens a nested Annotated (PEP 593): the marker joins the chain.
return Annotated[core, marker]
class _OperationFieldMarker:
"""Sentinel attached to :data:`OperationField`'s ``Annotated`` chain.
:data:`OperationField` erases its core type to ``Any`` (it cannot
name the operation base classes without an import cycle through
``sdk_``). That erasure also hides the field from the GUI's
``OperationRegistry``, which detects operation-valued parameters by
inspecting the annotation. This marker is the distinguishing token
the registry scans for — analogous to
:class:`~phenotypic.sdk_._column_ref._ColumnRefMarker` — so a
field typed ``OperationField`` (or ``list[OperationField]`` /
``OperationField | None``) is still recognised as accepting an
operation **or** a nested pipeline.
Singleton-like: all instances compare equal so a duplicate marker in
an ``Annotated`` chain de-dupes.
"""
__slots__ = ()
def __repr__(self) -> str:
return "_OperationFieldMarker()"
def __eq__(self, other: object) -> bool:
return isinstance(other, _OperationFieldMarker)
def __hash__(self) -> int:
return hash("_OperationFieldMarker")
def _lazy_base_operation() -> type:
from phenotypic.abc_ import BaseOperation
return BaseOperation
#: Annotated operation type usable as a pydantic field annotation for a
#: parameter that holds another operation or a nested pipeline.
#:
#: Back-compat alias — an operation/pipeline-valued field built by
#: :func:`polymorphic_field`. Serializes to a class-tagged dict so the
#: concrete subclass survives a JSON round-trip; deserializes by resolving
#: the class through the ``phenotypic`` registry. Use it directly
#: (``OperationField``) or inside a container — e.g. ``list[OperationField]``
#: — when the field must accept several operation types and round-trip each
#: losslessly.
#:
#: The core type is ``Any`` (naming the operation base classes here would
#: create an import cycle through ``sdk_``); an ``AfterValidator``
#: restores the operation/pipeline type guard. The trailing
#: :class:`_OperationFieldMarker` lets the GUI ``OperationRegistry``
#: recognise the field despite the ``Any`` erasure.
OperationField = polymorphic_field(
base=_lazy_base_operation, marker=_OperationFieldMarker()
)
[docs]
class TuneSpec:
"""Per-field tuning-search metadata (Tier-1 inference override).
``TuneSpec`` mirrors the existing field-marker pattern (``_ColumnRefMarker``,
``_OperationFieldMarker``): a frozen, slotted, **non-pydantic** sentinel that
is a complete no-op at runtime and is read *only* by ``infer_search_space``,
via ``op.model_fields[name].metadata`` (where pydantic v2 stores
``Annotated`` extras). It rides in an ``Annotated[T, TuneSpec(...)]`` chain::
class GaussianBlur(ImageEnhancer):
sigma: Annotated[float, TuneSpec(0.5, 5.0, log=True)] = 2.0
truncate: Annotated[float, TuneSpec(tunable=False)] = 4.0
At runtime ``sigma`` is still a plain ``float``; ``GaussianBlur(sigma=999.0)``
constructs exactly as before. The marker is the **search** domain, never the
**valid** domain — validity stays the job of pydantic ``Field(ge=, le=)``.
It lives here (not in ``phenotypic.tune``) so operation modules can import it
without dragging in the tune engine — the public re-export
``from phenotypic.tune import TuneSpec`` still works.
Args:
low: Inclusive lower search bound (positional). ``None`` leaves the
bound to Tier-2 inference / the co-located ``Field`` constraint.
high: Inclusive upper search bound (positional). ``None`` as ``low``.
step: Discretization stride — integer step or quantized float.
``None`` means continuous (or unit-step for integers).
log: Whether to sample on a logarithmic scale (default ``False``).
categories: Override / subset the auto-derived categorical choices.
A list is coerced to a tuple so the marker stays hashable.
tunable: ``False`` excludes the field from tuning outright and
short-circuits Tier-2 (default ``True``).
Note:
This is **not** a pydantic model — it is a plain slotted sentinel so it
can sit in an ``Annotated`` chain without pydantic trying to validate
it. It carries ``__eq__``/``__hash__`` over the full field tuple so a
duplicate in an ``Annotated`` chain de-dupes (PEP 593 chain semantics).
"""
__slots__ = ("low", "high", "step", "log", "categories", "tunable")
low: Optional[float]
high: Optional[float]
step: Optional[float]
log: bool
categories: Optional[tuple]
tunable: bool
def __init__(
self,
low: Optional[float] = None,
high: Optional[float] = None,
*,
step: Optional[float] = None,
log: bool = False,
categories: Optional[tuple] = None,
tunable: bool = True,
) -> None:
self.low = low
self.high = high
self.step = step
self.log = log
self.categories = tuple(categories) if categories is not None else None
self.tunable = tunable
def _as_tuple(self) -> tuple:
return (
self.low,
self.high,
self.step,
self.log,
self.categories,
self.tunable,
)
def __eq__(self, other: object) -> bool:
if not isinstance(other, TuneSpec):
return NotImplemented
return self._as_tuple() == other._as_tuple()
def __hash__(self) -> int:
return hash(self._as_tuple())
def __repr__(self) -> str:
return (
f"TuneSpec(low={self.low!r}, high={self.high!r}, "
f"step={self.step!r}, log={self.log!r}, "
f"categories={self.categories!r}, tunable={self.tunable!r})"
)