Source code for sigima.tools.image.geometry

# Copyright (c) DataLab Platform Developers, BSD 3-Clause license, see LICENSE file.

"""
Geometric analysis module
-------------------------

This module provides functions for geometric analysis of images, including
centroid detection, shape fitting, and spatial measurements.

Features include:

- Various centroid detection algorithms (Fourier-based, projected profile,
  automatic selection)
- Enclosing circle calculation for thresholded regions
- Radial profile extraction around specified centers
- Absolute level calculation from relative thresholds

These tools support precise geometric measurements and shape analysis
for scientific and technical image analysis applications.
"""

from __future__ import annotations

from typing import Literal

import numpy as np
from skimage import measure

from sigima.tools.checks import check_2d_array
from sigima.tools.image.preprocessing import fit_circle_model, get_absolute_level


[docs] @check_2d_array def get_centroid_fourier(data: np.ndarray) -> tuple[float, float]: """Return image centroid using Fourier algorithm Args: data: Input data Returns: Centroid coordinates (row, col) """ # Fourier transform method as discussed by Weisshaar et al. # (http://www.mnd-umwelttechnik.fh-wiesbaden.de/pig/weisshaar_u5.pdf) rows, cols = data.shape if rows == 1 or cols == 1: return 0, 0 i = np.arange(0, rows).reshape(1, rows) sin_a = np.sin((i - 1) * 2 * np.pi / (rows - 1)).T cos_a = np.cos((i - 1) * 2 * np.pi / (rows - 1)).T j = np.arange(0, cols).reshape(cols, 1) sin_b = np.sin((j - 1) * 2 * np.pi / (cols - 1)).T cos_b = np.cos((j - 1) * 2 * np.pi / (cols - 1)).T a = np.nansum((cos_a * data)) b = np.nansum((sin_a * data)) c = np.nansum((data * cos_b)) d = np.nansum((data * sin_b)) rphi = (0 if b > 0 else 2 * np.pi) if a > 0 else np.pi cphi = (0 if d > 0 else 2 * np.pi) if c > 0 else np.pi if a * c == 0.0: return 0, 0 row = (np.arctan(b / a) + rphi) * (rows - 1) / (2 * np.pi) + 1 col = (np.arctan(d / c) + cphi) * (cols - 1) / (2 * np.pi) + 1 row = np.nan if row is np.ma.masked else row col = np.nan if col is np.ma.masked else col return row, col
[docs] @check_2d_array def get_projected_profile_centroid( data: np.ndarray, smooth_ratio: float = 1 / 40, method: str = "median" ) -> tuple[float, float]: """ Estimate centroid from smoothed 1D projections. Args: data: 2D image array smooth_ratio: Ratio of smoothing window size (default: 1/40) method: 'median' (default) or 'barycenter' Returns: (y, x) coordinates """ x_profile = data.sum(axis=0) y_profile = data.sum(axis=1) window_size = max(1, int(min(data.shape) * smooth_ratio)) kernel = np.ones(window_size) / window_size x_profile = np.convolve(x_profile, kernel, mode="same") y_profile = np.convolve(y_profile, kernel, mode="same") x_profile -= np.min(x_profile) y_profile -= np.min(y_profile) if method == "median": x_integral = np.cumsum(x_profile) y_integral = np.cumsum(y_profile) x_center = np.interp( 0.5 * x_integral[-1], x_integral, np.arange(len(x_integral)) ) y_center = np.interp( 0.5 * y_integral[-1], y_integral, np.arange(len(y_integral)) ) elif method == "barycenter": # pragma: no cover # (ignored for coverage because median gives better results) x_center = np.sum(np.arange(len(x_profile)) * x_profile) / np.sum(x_profile) y_center = np.sum(np.arange(len(y_profile)) * y_profile) / np.sum(y_profile) else: raise ValueError("Unknown method: choose 'median' or 'barycenter'") return float(y_center), float(x_center)
[docs] @check_2d_array def get_centroid_auto( data: np.ndarray, return_method: bool = False, ) -> tuple[float, float] | tuple[float, float, Literal["fourier", "skimage"]]: """ Automatically select the most reliable centroid estimation method: - Prefer Fourier if it is more consistent with the projected median. - Fallback to scikit-image centroid if Fourier is less coherent. Args: data: 2D image array. return_method: If True, also return the name of the selected method. Returns: (row, col): Estimated centroid coordinates (float). Optionally, the selected method as string: "fourier" or "skimage". """ try: row_f, col_f = get_centroid_fourier(data) except Exception: # pylint: disable=broad-except row_f, col_f = float("nan"), float("nan") row_m, col_m = get_projected_profile_centroid(data, method="median") # Convert data (ndarray) to a simple array to compute centroid with the new # einsum optimisation introduce in numpy 2.4.0 and scikit-image 0.26.0 img = np.array(data) row_s, col_s = measure.centroid(img) dist_f = np.hypot(row_f - row_m, col_f - col_m) dist_s = np.hypot(row_s - row_m, col_s - col_m) if not (np.isnan(row_f) or np.isnan(col_f)) and dist_f < dist_s: result = (row_f, col_f) method = "fourier" else: result = (row_s, col_s) method = "skimage" return result + (method,) if return_method else result
[docs] @check_2d_array(non_constant=True) def get_enclosing_circle( data: np.ndarray, level: float = 0.5 ) -> tuple[int, int, float]: """Return (x, y, radius) for the circle contour enclosing image values above threshold relative level (.5 means FWHM) Args: data: Input data level: Relative level (default: 0.5) Returns: A tuple (x, y, radius) Raises: ValueError: No contour was found """ data_th = data.copy() data_th[data <= get_absolute_level(data, level)] = 0.0 contours = measure.find_contours(data_th) result = None max_radius = 1.0 for contour in contours: fit_result = fit_circle_model(contour) if fit_result: xc, yc, radius = fit_result if radius > max_radius: result = (int(xc), int(yc), radius) max_radius = radius if result is None: raise ValueError("No contour was found") return result