Source code for sigima.tools.image.preprocessing

"""
Signal/Image Preprocessing
--------------------------

This module contains utility functions for preprocessing and transforming image data:

- Binning and scaling operations
- Zero padding for Fourier analysis
- Utility functions for data transformation
- Compatibility helpers for scikit-image API changes

.. note::
    All functions in this module are also available directly in the parent
    `sigima.tools.image` package.
"""

from __future__ import annotations

from typing import Literal

import numpy as np
import scipy.spatial as spt
from numpy import ma
from packaging.version import Version
from skimage import __version__, measure

from sigima.enums import BinningOperation
from sigima.tools.checks import check_2d_array

# Check scikit-image version for API compatibility
# Version 0.26.0 introduced breaking changes to CircleModel and EllipseModel:
# - Old API: model.estimate(contour) + model.params
# - New API: model.from_estimate(contour) + model.center/radius/axis_lengths properties
_SKIMAGE_VERSION = Version(__version__)
_USE_NEW_SHAPE_API = _SKIMAGE_VERSION >= Version("0.26.0")


def fit_circle_model(contour: np.ndarray) -> tuple[float, float, float] | None:
    """Fit circle model to contour with version compatibility.

    Args:
        contour: Contour coordinates array (N, 2)

    Returns:
        Tuple (xc, yc, radius) or None if fitting fails
    """
    # pylint: disable=no-member
    if _USE_NEW_SHAPE_API:
        model = measure.CircleModel.from_estimate(contour)
        if model:
            # model.center is (row, col) = (y, x), swap to (x, y)
            return model.center[1], model.center[0], model.radius
    else:
        model = measure.CircleModel()
        if model.estimate(contour):
            yc, xc, radius = model.params
            return xc, yc, radius
    return None


def fit_ellipse_model(
    contour: np.ndarray,
) -> tuple[float, float, float, float, float] | None:
    """Fit ellipse model to contour with version compatibility.

    Args:
        contour: Contour coordinates array (N, 2)

    Returns:
        Tuple (xc, yc, a, b, theta) or None if fitting fails,
        where a and b are semi-major and semi-minor axes
    """
    # pylint: disable=no-member
    if _USE_NEW_SHAPE_API:
        model = measure.EllipseModel.from_estimate(contour)
        if model:
            # model.center is (row, col) = (y, x), swap to (x, y)
            # model.axis_lengths is (semi_row, semi_col), swap to (semi_x, semi_y)
            xc, yc = model.center[1], model.center[0]
            a, b = model.axis_lengths[1], model.axis_lengths[0]
            return xc, yc, a, b, model.theta
    else:
        model = measure.EllipseModel()
        if model.estimate(contour):
            yc, xc, b, a, theta = model.params
            return xc, yc, a, b, theta
    return None


[docs] def get_absolute_level(data: np.ndarray, level: float) -> float: """Get absolute level from relative level Args: data: Input data level: Relative level (0.0 to 1.0) Returns: Absolute level Raises: ValueError: If level is not a float between 0.0 and 1.0 """ if not isinstance(level, (int, float)) or level < 0.0 or level > 1.0: raise ValueError("Level must be a number between 0.0 and 1.0") return np.nanmin(data) + level * (np.nanmax(data) - np.nanmin(data))
[docs] def distance_matrix(coords: list) -> np.ndarray: """Return distance matrix from coords Args: coords: List of coordinates Returns: Distance matrix """ return np.triu(spt.distance.cdist(coords, coords, "euclidean"))
[docs] @check_2d_array def binning( data: np.ndarray, sx: int, sy: int, operation: BinningOperation | str, dtype=None, ) -> np.ndarray: """Perform image pixel binning Args: data: Input data sx: Binning size along x (number of pixels to bin together) sy: Binning size along y (number of pixels to bin together) operation: Binning operation dtype: Output data type (default: None, i.e. same as input) Returns: Binned data """ # Convert enum to string value if needed if isinstance(operation, BinningOperation): operation = operation.value ny, nx = data.shape shape = (ny // sy, sy, nx // sx, sx) try: bdata = data[: ny - ny % sy, : nx - nx % sx].reshape(shape) except ValueError as err: raise ValueError("Binning is not a multiple of image dimensions") from err if operation == "sum": bdata = np.array(bdata, dtype=float).sum(axis=(-1, 1)) elif operation == "average": bdata = bdata.mean(axis=(-1, 1)) elif operation == "median": bdata = ma.median(bdata, axis=(-1, 1)) elif operation == "min": bdata = bdata.min(axis=(-1, 1)) elif operation == "max": bdata = bdata.max(axis=(-1, 1)) else: valid = ", ".join(op.value for op in BinningOperation) raise ValueError(f"Invalid operation {operation} (valid values: {valid})") return np.array(bdata, dtype=data.dtype if dtype is None else np.dtype(dtype))
[docs] @check_2d_array(non_constant=True) def scale_data_to_min_max( data: np.ndarray, zmin: float | int, zmax: float | int ) -> np.ndarray: """Scale array `data` to fit [zmin, zmax] dynamic range Args: data: Input data zmin: Minimum value of output data zmax: Maximum value of output data Returns: Scaled data """ dmin, dmax = np.nanmin(data), np.nanmax(data) if dmin == zmin and dmax == zmax: return data fdata = np.array(data, dtype=float) fdata -= dmin fdata *= float(zmax - zmin) / (dmax - dmin) fdata += float(zmin) return np.array(fdata, data.dtype)
[docs] @check_2d_array def zero_padding( data: np.ndarray, rows: int = 0, cols: int = 0, position: Literal["bottom-right", "around"] = "bottom-right", ) -> np.ndarray: """ Zero-pad a 2D image by adding rows and/or columns. Args: data: 2D input image (grayscale) rows: Number of rows to add in total (default: 0) cols: Number of columns to add in total (default: 0) position: Padding placement strategy: - "bottom-right": all padding is added to the bottom and right - "around": padding is split equally on top/bottom and left/right Returns: The padded 2D image as a NumPy array. Raises: ValueError: If the input is not a 2D array or if padding values are negative. """ if rows < 0 or cols < 0: raise ValueError("Padding values must be non-negative") if position == "bottom-right": pad_width = ((0, rows), (0, cols)) elif position == "around": pad_width = ( (rows // 2, rows - rows // 2), (cols // 2, cols - cols // 2), ) else: raise ValueError(f"Invalid position: {position}") return np.pad(data, pad_width, mode="constant", constant_values=0)