1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224
|
"""
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:
return model.center[0], model.center[1], 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:
xc, yc = model.center[0], model.center[1]
a, b = model.axis_lengths[0], model.axis_lengths[1]
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
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))
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"))
@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))
@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)
@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)
|