Source code for phenotypic.abc_._gpu_detector

from __future__ import annotations

from abc import ABC, abstractmethod
from typing import TYPE_CHECKING, Annotated, Any, List

import numpy as np

from phenotypic.sdk_.typing_ import GpuInputLayer, GpuOutputKind, TuneSpec

if TYPE_CHECKING:
    from phenotypic._core._image import Image

from ._object_detector import ObjectDetector


# <<Interface>>
[docs] class GpuDetector(ObjectDetector, ABC): """Interface ABC for GPU-accelerated object detectors (batched/streaming). Subclass GpuDetector when your detection algorithm depends on a GPU (e.g., deep-learning foundation models like SAM2 or micro-sam). GpuDetector provides a concrete ``_operate`` built from a small set of protected, overridable hooks — ``_preprocess`` (raw ``input_layer`` array → model-ready sample), ``_collate`` (samples → batch), ``_infer_batch`` (batch → per-sample results), and ``_write_object_output`` (result → ``objmap``/``objmask``). The single-image notebook path and the batched CLI engine drive the *same* hooks, so a detector implemented once runs in both. Capability is declared via three fields — ``input_layer`` (``rgb``/``gray``/``detect_mat``; defaults to the layer the model was trained on), ``supports_batching``, and ``output_kind`` (``instance``/``semantic``). When a pipeline contains a GpuDetector, the CLI enforces: - **Local execution:** Sequential processing (n_jobs=1) to avoid multiple workers competing for the same GPU. - **SLURM execution:** Automatically requests GPU resources (``--gpus-per-node=1``) if the user hasn't specified GPU args. Raises an error if the target partition has no GPUs. - **No GPU available:** Raises RuntimeError at pipeline validation time with a clear message. **When to subclass GpuDetector vs ObjectDetector** Subclass GpuDetector if your detector relies on GPU-accelerated inference: - Deep-learning models (SAM2, micro-sam, or custom neural networks). - Any algorithm that requires ``torch``, ``tensorflow``, or similar GPU-backed frameworks at inference time. - Detectors where CPU fallback is technically possible but impractically slow for production use. Subclass ObjectDetector directly if your algorithm is CPU-based: - Classical computer vision (thresholding, edge detection, watershed). - Algorithms implemented with NumPy, SciPy, or scikit-image. - Detectors that run in milliseconds on CPU. **Lazy model loading** GpuDetector subclasses should defer model construction to the first ``apply()`` call rather than ``__init__()``. This enables: - Fast construction and serialization round-trips without GPU/torch. - Pipeline ``to_json()``/``from_json()`` without importing heavy dependencies. - Parameter inspection and validation before committing GPU memory. Use a ``_ensure_model_loaded()`` pattern:: from pydantic import PrivateAttr class MyGpuDetector(GpuDetector): model_size: str = "small" # Annotated class-level fields device: str = "auto" # underscore-prefixed private attr → skipped by serialization _model: object = PrivateAttr(default=None) def _ensure_model_loaded(self): if self._model is not None: return import torch # lazy import # ... build model ... def _infer_one(self, sample): # ``sample`` is a preprocessed (H, W, 3) uint8 array. Return a # uint16 labeled objmap (output_kind="instance") or a bool mask # (output_kind="semantic"). The base _operate/_infer_batch wire # this into the image; do NOT override _operate. # ... run inference ... return objmap Notes: ``_operate`` is concrete here and should not be overridden. Non-batchable subclasses (SAM2, micro-sam) implement just ``_ensure_model_loaded`` + ``_infer_one``; the default ``_infer_batch`` loops ``_infer_one`` and is the sole caller of ``_ensure_model_loaded``. Batchable subclasses (Spec 2 foundation models) instead override ``_infer_batch`` with a true ``(N, C, H, W)`` forward — no engine changes needed. The class also lets the CLI make informed GPU resource-allocation decisions. """ # Capability / routing markers — pydantic FIELDS (not ClassVar) so they # serialize and round-trip (Spec 1 §4, review S4). Subclasses override the # defaults; "instance" keeps existing SAM behavior unchanged. input_layer: GpuInputLayer = "rgb" supports_batching: bool = False output_kind: GpuOutputKind = "instance" # Post-inference cleanup: split a single instance label that spans spatially # disconnected blobs into separate instances by connected components. A SAM # mask (or a tile-merged objmap) can paint one label across distant regions; # relabeling by connectivity gives each connected region its own id. Binary # connected-components, so two *touching* distinct labels merge into one. # ``instance`` output only — ``semantic`` already auto-labels by connectivity. split_disconnected_labels: bool = True # Connectivity for the relabel (1 = 4-neighbour, 2 = 8-neighbour). Structural, # never tuned (TuneSpec(tunable=False) satisfies the annotation-coverage gate). connectivity: Annotated[int, TuneSpec(tunable=False)] = 2 @abstractmethod def _ensure_model_loaded(self) -> None: """Build/load the GPU model on first use (idempotent).""" def _preprocess(self, array: np.ndarray) -> Any: """Turn a raw ``input_layer`` array into a model-ready ``uint8`` sample. Single-channel 2D layers (``gray``/``detect_mat``) are stacked into an ``(H, W, 3)`` block so 3-channel models (SAM/DINO ViT) consume them unchanged; ``rgb`` keeps its three channels. The result is then coerced to ``uint8`` — float ``[0, 1]`` (``gray``/``detect_mat``) and ``uint16`` (16-bit ``rgb``) layers are max-normalized to ``0..255``, while an already-``uint8`` ``rgb`` array passes through byte-identical — so every layer reaches the model through this one shared conversion. Subclasses rarely need to override this. """ if array.ndim == 2: array = np.stack([array, array, array], axis=-1) if array.dtype != np.uint8: max_val = array.max() if max_val > 0: array = (array / max_val * 255).astype(np.uint8) else: array = np.zeros(array.shape, dtype=np.uint8) return array def _collate(self, samples: List[Any]) -> Any: """Merge per-sample ``_preprocess`` outputs into a batch. Default returns the list unchanged (consumed by the looped ``_infer_batch``). Batchable subclasses override to stack into a tensor. """ return samples def _infer_batch(self, batch: Any) -> List[np.ndarray]: """Run inference over a collated batch; return one result per sample. Each result is a uint16 labeled map (``output_kind="instance"``) or a boolean mask (``output_kind="semantic"``). The default loops ``_infer_one`` (correct for ``supports_batching=False``); batchable subclasses override with a true ``(N, C, H, W)`` forward. """ self._ensure_model_loaded() return [self._infer_one(sample) for sample in batch] def _infer_one(self, sample: Any) -> np.ndarray: """Run the model on ONE preprocessed sample. Subclasses must implement. Returns a uint16 labeled objmap (instance) or a boolean mask (semantic). """ raise NotImplementedError( f"{type(self).__name__} must implement _infer_one()" ) def _write_object_output(self, image: "Image", result: np.ndarray) -> None: """Write one ``infer_batch`` result onto the image per ``output_kind``. - ``instance`` -> ``image.objmap[:]`` (detector-controlled labels). When ``split_disconnected_labels`` is set, the written objmap is then relabeled by connected components (``connectivity``) so one label spanning spatially disconnected blobs becomes separate instances. - ``semantic`` -> ``image.objmask[:]`` (auto-labels into the shared ``objmap`` backend, exactly like a threshold detector; see Spec 1 §8). Already connectivity-labeled, so ``split_disconnected_labels`` is a no-op here. """ if self.output_kind == "instance": image.objmap[:] = result.astype(np.uint16) if self.split_disconnected_labels: image.objmap.relabel(connectivity=self.connectivity) else: # semantic image.objmask[:] = result.astype(bool) def _operate(self, image: "Image") -> "Image": """Run GPU detection on one image (notebook / single-image path). Reads the declared ``input_layer``, preprocesses, runs a one-element batch through ``_collate`` + ``_infer_batch``, and writes the result via ``output_kind``. The batched CLI engine drives the same ``_preprocess``/``_collate``/``_infer_batch`` methods over many images. """ array = getattr(image, self.input_layer)[:] sample = self._preprocess(array) batch = self._collate([sample]) results = self._infer_batch(batch) self._write_object_output(image, results[0]) return image