Source code for pykrait.preprocessing.segmentation

import dask.array as da
import numpy as np
import warnings
import os
from cellpose import models
from tqdm.dask import TqdmCallback
from typing import Literal
from skimage import exposure


def _scale_to_uint16(arr: np.ndarray) -> np.ndarray:
    """Wraps skimage's rescale_intensity to convert an array to 16-bit unsigned integer format.

    :param arr: input array
    :type arr: np.ndarray
    :return: returns the input array scaled to 16-bit unsigned integer format.
    :rtype: np.ndarray
    """
    return exposure.rescale_intensity(arr, out_range="uint16").astype(np.uint16)


def _fast_time_sum(timelapse: da.Array) -> np.ndarray:
    T, C, Y, X = timelapse.shape

    # Rechunk T so blocks are as large as memory allows
    rechunked = timelapse.rechunk({0: "auto"})

    # Reduce each T-chunk to (1, C, Y, X)
    partial_sums = rechunked.map_blocks(
        lambda block: block.sum(axis=0, keepdims=True),
        dtype=timelapse.dtype,
        chunks=(1, C, Y, X),
    )

    # Sum across all (1, C, Y, X) blocks
    final_sum = partial_sums.sum(axis=0)

    return final_sum


def _fast_time_std(timelapse: da.Array) -> np.ndarray:
    """
    Efficiently computes standard deviation over the time axis (axis=0) of a T,C,Y,X dask array.
    Uses Dask-native operations for parallel execution and memory efficiency.

    :param timelapse: Dask array with shape (T, C, Y, X)
    :return: Dask array of shape (C, Y, X) with std computed over T
    """
    T, C, Y, X = timelapse.shape

    # Rechunk T to optimize chunk sizes
    rechunked = timelapse.rechunk({0: "auto"})

    # First pass: compute mean per block and total sum
    partial_sums = rechunked.map_blocks(
        lambda block: block.sum(axis=0, keepdims=True),
        dtype=timelapse.dtype,
        chunks=(1, C, Y, X),
    )
    total_sum = partial_sums.sum(axis=0)
    mean = total_sum / T

    # Second pass: compute squared differences from mean per block
    def squared_diff_block(block, mean):
        return ((block - mean) ** 2).sum(axis=0, keepdims=True)

    # Broadcast mean to match chunk shapes
    partial_squared_diffs = rechunked.map_blocks(
        squared_diff_block, mean, dtype=timelapse.dtype, chunks=(1, C, Y, X)
    )

    # Sum all squared diffs and divide by T (or T-1 for sample std)
    total_squared_diff = partial_squared_diffs.sum(axis=0)
    std = da.sqrt(total_squared_diff / T)

    return std


[docs] def timelapse_projection( lazy_timelapse: da.array, normalize: bool = True, method: Literal["std", "sum"] = "std", verbose: bool = True, ) -> np.ndarray: """ Computes a T-projection of a timelapse to be used for cell segmentation. :param lazy_timelapse: 4D dask array representing the timelapse, with shape order "TCYX". :type lazy_timelapse: da.array :param normalize: If True, applies CLAHE normalization. :type normalize: bool, optional :param method: method to be used for the projection, either "std" or "sum" for sum, defaults to "std" :type method: Literal["std", "sum"], optional, optional :param verbose: whether to output progress bar, defaults to True :type verbose: bool, optional :raises TypeError: if input is not a dask array :raises ValueError: if input does not have 4 dimensions :raises ValueError: if unknown T-proj method :return: 3D "CYX" numpy array containing the standard deviation for each pixel across all frames in 16-bit. :rtype: np.ndarray """ # Check if the input is a 4D dask array if not isinstance(lazy_timelapse, da.Array): raise TypeError("Input must be a dask array.") if lazy_timelapse.ndim != 4: raise ValueError("Input timelapse must be a 4D array with shape TCYX.") T, C, Y, X = lazy_timelapse.shape if T < C or T > Y or T > X or C > Y or C > X: warnings.warn( f"Input array shape of {lazy_timelapse.shape} is unusual. Is it in TCYX order?", UserWarning, ) # lazy_timelapse = lazy_timelapse.rechunk({0: -1, 2: "auto", 3: "auto"}) if method == "std": if verbose: with TqdmCallback(desc="Computing STD projection"): t_proj = _fast_time_std(lazy_timelapse).compute(scheduler="threads") else: t_proj = _fast_time_std(lazy_timelapse).compute(scheduler="threads") t_proj = np.nan_to_num(t_proj, nan=0.0) # Replace NaNs with 0 t_proj = _scale_to_uint16(t_proj) # Scale to uint16 elif method == "sum": if verbose: with TqdmCallback(desc="Computing SUM projection"): t_proj = _fast_time_sum(lazy_timelapse).compute(scheduler="threads") else: t_proj = _fast_time_sum(lazy_timelapse).compute(scheduler="threads") else: raise ValueError("T-Projection Method must be either 'std' or 'sum'.") if normalize: # Enhance contrast per channel (C, Y, X) enhanced = np.empty_like(t_proj) for c in range(t_proj.shape[0]): enhanced[c] = _scale_to_uint16(exposure.equalize_adapthist(t_proj[c])) else: enhanced = _scale_to_uint16(t_proj) return enhanced
[docs] def create_cellpose_segmentation( image: np.ndarray, cellpose_model_path: str ) -> np.ndarray: """ Wrapper function to create a label image from an input image using a prespecified cellpose model. :param image: image to be processed with cellpose. :type image: ndarray :param cellpose_model_path: path to the cellpose model file. Can also be cpsam if using cellpose-4 and the standard builtin model. :type cellpose_model_path: str :return label_img: The generated label image. :rtype: np.ndarray :raises FileNotFoundError: If the Cellpose model path does not exist. :raises ValueError: If the input std image is not a 2D array. :raises RuntimeError: If the Cellpose model fails to load with both GPU and CPU backends. """ # check if the model path exists if cellpose_model_path != "cpsam" and not os.path.isfile(cellpose_model_path): raise FileNotFoundError( "Cellpose model not found at {}".format(cellpose_model_path) ) # TODO: check if the model was trained using the same cellpose version if image.ndim > 3: raise ValueError( "Input image must be a 2D (YX) or 3D (CYX) array but got shape {}".format( image.shape ) ) # setting up the cellpose segmentation, trying GPU first and falling back to CPU if it fails try: model = models.CellposeModel(gpu=True, pretrained_model=cellpose_model_path) except Exception as e: print(f"Error loading Cellpose model wiht GPU backend due to: {e}") try: model = models.CellposeModel( gpu=False, pretrained_model=cellpose_model_path ) except Exception as e: raise RuntimeError( f"Failed to load Cellpose model with CPU backend due to: {e}" ) mask, _, _ = model.eval(image) if np.max(mask) == 0: warnings.warn( "The generated mask contains only zeros. No cells were detected.", UserWarning, ) return mask