Source: bactoscoop/utilities.pyΒΆ

   1# -*- coding: utf-8 -*-
   2"""
   3Created on Wed Feb 22 13:46:15 2023
   4
   5@author: Bart Steemans. Govers Lab. 
   6"""
   7import os
   8import shutil
   9import tifffile
  10import logging
  11
  12import numpy as np
  13import cv2 as cv
  14import networkx as nx
  15import math
  16from shapely import Polygon
  17from .logging_utils import tqdm_if_verbose
  18import pandas as pd
  19from itertools import combinations
  20import matplotlib.pyplot as plt
  21
  22from scipy import interpolate, ndimage, spatial
  23from scipy.ndimage import gaussian_filter1d, fourier_shift, gaussian_laplace
  24from scipy.signal import find_peaks
  25from scipy.stats import kurtosis, skew
  26
  27from skimage.feature import graycomatrix, graycoprops
  28from skimage.filters import threshold_otsu
  29from skimage.draw import polygon2mask
  30from skimage.morphology import skeletonize, closing
  31from skimage.measure import regionprops_table, regionprops, label, shannon_entropy
  32from skimage.registration import phase_cross_correlation
  33from scipy.optimize import curve_fit
  34
  35bactoscoop_logger = logging.getLogger("logger")
  36
  37
  38# -------------------------------------------------------------------------------------------------------
  39# FUNCTIONS
  40# %% Cellular mesh creation functions
  41def smallest_path(x_coords, y_coords, start_x, start_y):
  42    G = nx.Graph()
  43    for i in range(len(x_coords)):
  44        G.add_node(i, x=x_coords[i], y=y_coords[i])
  45    for i in range(len(x_coords)):
  46        for j in range(i + 1, len(x_coords)):
  47            G.add_edge(
  48                i,
  49                j,
  50                weight=np.sqrt(
  51                    (x_coords[i] - x_coords[j]) ** 2 + (y_coords[i] - y_coords[j]) ** 2
  52                ),
  53            )
  54    start = None
  55    for i in range(len(x_coords)):
  56        if x_coords[i] == start_x and y_coords[i] == start_y:
  57            start = i
  58            break
  59    T = nx.minimum_spanning_tree(G)
  60    path = nx.dfs_postorder_nodes(T, source=start)
  61    sorted_points = list(path)
  62    return [x_coords[i] for i in sorted_points], [y_coords[i] for i in sorted_points]
  63
  64
  65def skeleton_endpoints(skel):
  66
  67    skel = skel.copy()
  68    skel[skel != 0] = 1
  69    skel = np.uint8(skel)
  70
  71    # Apply the convolution.
  72    kernel = np.uint8([[1, 1, 1], [1, 10, 1], [1, 1, 1]])
  73
  74    filtered = cv.filter2D(skel, -1, kernel)
  75
  76    out = np.zeros_like(skel)
  77    out[np.where(filtered == 11)] = 1
  78    return out
  79
  80
  81def get_ordered_list(points, x, y):
  82    distance = ((x - points[1]) ** 2 + (y - points[0]) ** 2) ** 0.5
  83    args = np.argsort(distance)
  84    return args
  85
  86
  87# @jit(nopython=True, cache=True)
  88# MOMIA
  89def line_intersect(x1, y1, x2, y2, x3, y3, x4, y4):  # Ax+By = C
  90    A1 = y2 - y1
  91    B1 = x1 - x2
  92    C1 = A1 * x1 + B1 * y1
  93    A2 = y4 - y3
  94    B2 = x3 - x4
  95    C2 = A2 * x3 + B2 * y3
  96    intersect_x = (B1 * C2 - B2 * C1) / (A2 * B1 - A1 * B2)
  97    intersect_y = (A1 * C2 - A2 * C1) / (B2 * A1 - B1 * A2)
  98    return intersect_x, intersect_y
  99
 100
 101# MOMIA
 102def line_contour_intersection(p1, p2, contour):
 103    v1, v2 = contour[:-1], contour[1:]
 104    x1, y1 = v1.T
 105    x2, y2 = v2.T
 106    x3, y3 = p1
 107    x4, y4 = p2
 108    xy = np.array(line_intersect(x1, y1, x2, y2, x3, y3, x4, y4)).T
 109    dxy_v1 = xy - v1
 110    dxy_v2 = xy - v2
 111    dxy = dxy_v1 * dxy_v2
 112    intersection_points = xy[np.where(np.logical_and(dxy[:, 0] < 0, dxy[:, 1] < 0))]
 113    if len(intersection_points) > 2:
 114        dist = np.sum(
 115            np.square(np.tile(p1, (len(intersection_points), 1)) - intersection_points),
 116            axis=1,
 117        )
 118        intersection_points = intersection_points[np.argsort(dist)[0:2]]
 119    return intersection_points
 120
 121
 122# MOMIA
 123def unit_perpendicular_vector(data, closed=True):
 124
 125    p1 = data[1:]
 126    p2 = data[:-1]
 127    dxy = p1 - p2
 128    ang = np.arctan2(dxy.T[1], dxy.T[0]) + 0.5 * np.pi
 129    dx, dy = np.cos(ang), np.sin(ang)
 130    unit_dxy = np.array([dx, dy]).T
 131    if not closed:
 132        unit_dxy = np.concatenate([[unit_dxy[0]], unit_dxy])
 133    else:
 134        unit_dxy = np.concatenate([unit_dxy, [unit_dxy[-1]]])
 135    return unit_dxy
 136
 137
 138def get_major_axis_line_coordinates(masks):
 139    # Find contours in the mask
 140    contours, _ = cv.findContours(
 141        masks.astype(np.uint8), cv.RETR_EXTERNAL, cv.CHAIN_APPROX_NONE
 142    )
 143
 144    ellipse = cv.fitEllipse(contours[0])
 145
 146    # Extract major axis parameters
 147    # Extract major axis parameters
 148    center, (major_axis, minor_axis), angle = ellipse
 149
 150    # Calculate endpoints of the major axis with reversed orientation
 151    angle_rad = np.deg2rad(angle)
 152    cos_angle = np.cos(angle_rad)
 153    sin_angle = np.sin(angle_rad)
 154
 155    # Center coordinates of the major axis
 156    mid_x = center[0]
 157    mid_y = center[1]
 158
 159    # Endpoint coordinates of the major axis with reversed orientation
 160    endpoint1_x = int(mid_x + 0.5 * major_axis * sin_angle)
 161    endpoint1_y = int(mid_y - 0.5 * major_axis * cos_angle)
 162    endpoint2_x = int(mid_x - 0.5 * major_axis * sin_angle)
 163    endpoint2_y = int(mid_y + 0.5 * major_axis * cos_angle)
 164
 165    # Generate coordinates along the major axis line
 166    line_x = np.linspace(endpoint1_x, endpoint2_x, num=200)
 167    line_y = np.linspace(endpoint1_y, endpoint2_y, num=200)
 168
 169    # Combine x and y coordinates into an array of shape (n, 2) with (x, y) convention
 170    line_coordinates = np.column_stack((line_y, line_x))
 171    return line_coordinates
 172
 173
 174def extract_skeleton(masks):
 175    skeleton = skeletonize(masks, method="lee")
 176
 177    # Retrieve the skeleton coordinates in the original image space
 178    skeleton_coords = np.column_stack(np.where(skeleton)).astype(int)
 179
 180    x_skel = skeleton_coords[:, 0]
 181    y_skel = skeleton_coords[:, 1]
 182
 183    skel_mask = np.zeros_like(masks)
 184    skel_mask[x_skel, y_skel] = 1
 185
 186    # Sort coordinates based on distance
 187    skel_end = skeleton_endpoints(skel_mask)
 188    try:
 189
 190        end_coords = np.argwhere(skel_end == 1)[0]
 191
 192    except IndexError:
 193
 194        return get_major_axis_line_coordinates(masks)
 195
 196    sorted_skelx, sorted_skely = smallest_path(
 197        x_skel, y_skel, end_coords[0], end_coords[1]
 198    )
 199
 200    if len(y_skel) <= 3:
 201        return get_major_axis_line_coordinates(masks)
 202
 203    elif 4 <= len(y_skel) <= 13:
 204
 205        fkc, u = interpolate.splprep([sorted_skelx, sorted_skely], k=1, s=1, per=0)
 206    else:
 207        fkc, u = interpolate.splprep([sorted_skelx, sorted_skely], k=2, s=10, per=0)
 208    smoothed_skel = np.asarray(
 209        interpolate.splev(np.linspace(u.min(), u.max(), 200), fkc, der=0)
 210    ).T
 211    return smoothed_skel
 212
 213
 214# MOMIA
 215def find_poles(smoothed_skeleton, smoothed_contour, find_pole1=True, find_pole2=True):
 216    # find endpoints and their nearest neighbors on a midline
 217    length = len(smoothed_skeleton)
 218    extended_pole1 = [smoothed_skeleton[0]]
 219    extended_pole2 = [smoothed_skeleton[-1]]
 220    i = 0
 221    j = 0
 222    if find_pole1:
 223        for i in range(10):
 224            p1 = smoothed_skeleton[i]
 225            p2 = smoothed_skeleton[i + 1]
 226            # find the two intersection points between
 227            # the vectorized contour and line through pole1
 228            intersection_points_pole1 = line_contour_intersection(
 229                p1, p2, smoothed_contour
 230            )
 231            # find the interesection point with the same direction as the outward pole vector
 232            dxy_1 = p1 - p2
 233            p1_tile = np.tile(p1, (len(intersection_points_pole1), 1))
 234            p1dot = (intersection_points_pole1 - p1_tile) * dxy_1
 235            index_1 = np.where((p1dot[:, 0] > 0) & (p1dot[:, 1] > 0))[0]
 236            if len(index_1) > 0:
 237                extended_pole1 = intersection_points_pole1[index_1]
 238                break
 239    else:
 240        i = 1
 241
 242    if find_pole2:
 243        for j in range(10):
 244            p3 = smoothed_skeleton[-1 - j]
 245            p4 = smoothed_skeleton[-2 - j]
 246            # find the two intersection points between
 247            # the vectorized contour and line through pole1
 248            intersection_points_pole2 = line_contour_intersection(
 249                p3, p4, smoothed_contour
 250            )
 251            # find the interesection point with the same direction as the outward pole vector
 252            dxy_2 = p3 - p4
 253            p3_tile = np.tile(p3, (len(intersection_points_pole2), 1))
 254            p3dot = (intersection_points_pole2 - p3_tile) * dxy_2
 255            index_2 = np.where((p3dot[:, 0] > 0) & (p3dot[:, 1] > 0))[0]
 256            if len(index_2) > 0:
 257                extended_pole2 = intersection_points_pole2[index_2]
 258                break
 259    else:
 260        j = 1
 261    trimmed_midline = smoothed_skeleton[i : length - j]
 262    return extended_pole1, extended_pole2, trimmed_midline
 263
 264
 265# MOMIA
 266def extend_skeleton(
 267    smoothed_skeleton, smoothed_contour, find_pole1=True, find_pole2=True
 268):
 269    # initiate approximated tip points
 270    new_pole1, new_pole2, trimmed_skeleton = find_poles(
 271        smoothed_skeleton,
 272        smoothed_contour,
 273        find_pole1=find_pole1,
 274        find_pole2=find_pole2,
 275    )
 276    extended_skeleton = np.concatenate([new_pole1, trimmed_skeleton, new_pole2])
 277
 278    extended_skeleton = spline_approximation(
 279        extended_skeleton, n=200, smooth_factor=1, closed=False
 280    )
 281    return extended_skeleton, new_pole1, new_pole2
 282
 283
 284# MOMIA
 285def straighten_by_orthogonal_lines(contour, midline, length, width, unit_micron=0.5):
 286    # estimate profile mesh size
 287    median_width = np.median(width)
 288    N_length = int(round(length / unit_micron))
 289    N_width = int(round(median_width / unit_micron))
 290    midline = spline_approximation(midline, n=N_length, smooth_factor=1, closed=False)
 291
 292    half_contour_1, half_contour_2 = divide_contour_by_midline(
 293        midline, contour
 294    )  # [:-1]
 295    # infer orthogonal vectors
 296    ortho_unit_vectors = unit_perpendicular_vector(midline)
 297    # generate orthogonal profile lines for all midline points except for the polar ones
 298    l1 = orthogonal_intersection_point(
 299        midline, half_contour_1, precomputed_orthogonal_vector=ortho_unit_vectors
 300    )
 301
 302    l2 = orthogonal_intersection_point(
 303        midline, half_contour_2, precomputed_orthogonal_vector=ortho_unit_vectors
 304    )
 305
 306    dl = (l2 - l1) / N_width
 307    mult_mat = np.tile(np.arange(N_width + 1), (len(l1), 1))
 308    mat_x = l1[:, 0][:, np.newaxis] + mult_mat * dl[:, 0][:, np.newaxis]
 309    mat_y = l1[:, 1][:, np.newaxis] + mult_mat * dl[:, 1][:, np.newaxis]
 310    profile_mesh = np.array([mat_x, mat_y])
 311    return l1, l2, profile_mesh, midline
 312
 313
 314def radial_intensities(
 315    cropped_signal_interp2d,
 316    contour,
 317    width,
 318    min_val,
 319    max_val,
 320    num_erosions,
 321    erosion_scale=None,
 322):
 323
 324    erosion = width / (2 * num_erosions)
 325    if erosion_scale is not None:
 326        erosion = erosion_scale
 327
 328    eroded_contours = [contour]
 329    for i in range(num_erosions):
 330        eroded_contour = erode_contour(eroded_contours[-1], scale=erosion)
 331        eroded_contours.append(eroded_contour)
 332
 333    eroded_contours.reverse()
 334
 335    intensities_per_mesh = []
 336    for contour in eroded_contours:
 337        intensities = measure_smoothened_intensity(
 338            contour, cropped_signal_interp2d, width=erosion, subpixel=0.1
 339        )
 340        normalized_intensities = (intensities - min_val) / (max_val - min_val)
 341        average_intensity = np.average(normalized_intensities)
 342        intensities_per_mesh.append(average_intensity)
 343
 344    return intensities_per_mesh
 345
 346
 347# MOMIA
 348def spline_approximation(init_contour, n=200, smooth_factor=1, closed=True):
 349    if closed:
 350        tck, u = interpolate.splprep(init_contour.T, u=None, s=smooth_factor, per=1)
 351    else:
 352        tck, u = interpolate.splprep(init_contour.T, u=None, s=smooth_factor)
 353    u_new = np.linspace(u.min(), u.max(), n)
 354    x_new, y_new = interpolate.splev(u_new, tck, der=0)
 355    return np.array([x_new, y_new]).T
 356
 357
 358# MOMIA
 359def divide_contour_by_midline(midline, contour):
 360    dist1 = distance_matrix(contour, midline[0]).flatten()
 361    dist2 = distance_matrix(contour, midline[-1]).flatten()
 362
 363    id1, id2 = np.argsort(dist1)[:2]
 364    id3, id4 = np.argsort(dist2)[:2]
 365
 366    contour_cp = contour.copy()
 367    if max(id1, id2) < max(id3, id4):
 368        term_p1 = max(id1, id2)
 369        if abs(id3 - id4) == 1:
 370            term_p2 = max(id3, id4) + 1
 371        elif abs(id3 - id4) > 1:
 372            term_p2 = max(id3, id4) + 2
 373        contour_cp = np.insert(contour_cp, term_p1, midline[0], axis=0)
 374        contour_cp = np.insert(contour_cp, term_p2, midline[-1], axis=0)
 375
 376    else:
 377        term_p1 = max(id3, id4)
 378        if abs(id1 - id2) == 1:
 379            term_p2 = max(id1, id2) + 1
 380        elif abs(id1 - id2) > 1:
 381            term_p2 = max(id1, id2) + 2
 382        contour_cp = np.insert(contour_cp, term_p1, midline[-1], axis=0)
 383        contour_cp = np.insert(contour_cp, term_p2, midline[0], axis=0)
 384
 385    if term_p1 == term_p2:
 386        raise ValueError("Two endpoints are identical!")
 387    else:
 388        pos1, pos2 = sorted([term_p1, term_p2])
 389        # print(id1, id2, id3, id4, pos1, pos2, len(contour_cp))
 390        half_contour_1 = contour_cp[pos1 : min(pos2 + 1, len(contour_cp) - 1)]
 391        half_contour_2 = np.concatenate([contour_cp[pos2:], contour_cp[: pos1 + 1]])
 392    return half_contour_1, half_contour_2
 393
 394
 395# MOMIA
 396def distance_matrix(data1, data2):
 397    x1, y1 = data1.T
 398    x2, y2 = data2.T
 399    dx = x1[:, np.newaxis] - x2
 400    dy = y1[:, np.newaxis] - y2
 401    dxy = np.sqrt(dx**2 + dy**2)
 402    return dxy
 403
 404
 405# MOMIA
 406def orthogonal_intersection_point(
 407    midline, outerline, precomputed_orthogonal_vector=None, min_dist=1e-100
 408):
 409    v1, v2 = outerline[:-1], outerline[1:]
 410    skel_x, skel_y = midline.T
 411    if precomputed_orthogonal_vector is None:
 412        intersect_x, intersect_y = intersect_matrix(midline, outerline)
 413    else:
 414        intersect_x, intersect_y = intersect_matrix(
 415            midline, outerline, orthogonal_vectors=precomputed_orthogonal_vector
 416        )
 417    dx_v1 = intersect_x - v1.T[0][:, np.newaxis]
 418    dx_v2 = intersect_x - v2.T[0][:, np.newaxis]
 419    dy_v1 = intersect_y - v1.T[1][:, np.newaxis]
 420    dy_v2 = intersect_y - v2.T[1][:, np.newaxis]
 421    dx = dx_v1 * dx_v2
 422    dy = dy_v1 * dy_v2
 423
 424    dist_x = skel_x[np.newaxis, :] - intersect_x
 425    dist_y = skel_y[np.newaxis, :] - intersect_y
 426    # influence on extreme points
 427    non_bounadry_points = np.where(np.logical_and(dy >= 0, dx >= 0))
 428    dist_matrix = np.sqrt(dist_x**2 + dist_y**2)
 429    dist_matrix[non_bounadry_points] = np.inf
 430    dist_matrix[dist_matrix <= min_dist] = np.inf
 431
 432    nearest_id_x = np.argsort(dist_matrix, axis=0)[:1]
 433    nearest_id_y = np.linspace(
 434        0, dist_matrix.shape[1] - 1, dist_matrix.shape[1]
 435    ).astype(int)
 436    pos_list = np.array(
 437        [
 438            intersect_x[nearest_id_x[0], nearest_id_y],
 439            intersect_y[nearest_id_x[0], nearest_id_y],
 440        ]
 441    ).T
 442    return pos_list
 443
 444
 445# MOMIA
 446def intersect_matrix(line, contour, orthogonal_vectors=None):
 447    if orthogonal_vectors is None:
 448        dxy = unit_perpendicular_vector(line, closed=False)
 449    else:
 450        dxy = orthogonal_vectors
 451    v1, v2 = contour[:-1], contour[1:]
 452    x1, y1 = v1.T
 453    x2, y2 = v2.T
 454    x3, y3 = line.T
 455    perp_xy = line + dxy
 456    x4, y4 = perp_xy.T
 457    A1 = y2 - y1
 458    B1 = x1 - x2
 459    C1 = A1 * x1 + B1 * y1
 460    A2 = y4 - y3
 461    B2 = x3 - x4
 462    C2 = A2 * x3 + B2 * y3
 463
 464    A1B2 = A1[:, np.newaxis] * B2
 465    A1C2 = A1[:, np.newaxis] * C2
 466    B1A2 = B1[:, np.newaxis] * A2
 467    B1C2 = B1[:, np.newaxis] * C2
 468    C1A2 = C1[:, np.newaxis] * A2
 469    C1B2 = C1[:, np.newaxis] * B2
 470    # can give outliers when B1A2 = A1B2, division by zero or close to zero
 471    intersect_x = (B1C2 - C1B2) / (B1A2 - A1B2)
 472    intersect_y = (A1C2 - C1A2) / (A1B2 - B1A2)
 473    return intersect_x, intersect_y
 474
 475
 476def add_poles(coords1, coords2, pole1, pole2):
 477    coords1 = np.vstack([pole1, coords1[1:-1], pole2])
 478    coords2 = np.vstack([pole1, coords2[1:-1], pole2])
 479    result = np.stack(
 480        [coords1[:, 0], coords1[:, 1], coords2[:, 0], coords2[:, 1]], axis=1
 481    )
 482    return result, coords1, coords2
 483
 484
 485# MOMIA
 486def line_length(line):
 487    v1 = line[:-1]
 488    v2 = line[1:]
 489    d = v2 - v1
 490    return np.sum(np.sqrt(np.sum(d**2, axis=1)))
 491
 492
 493# MOMIA
 494def direct_intersect_distance(skeleton, contour):
 495
 496    v1, v2 = contour[:-1], contour[1:]
 497    skel_x, skel_y = skeleton[1:-1].T
 498    intersect_x, intersect_y = intersect_matrix(skeleton[1:-1], contour)
 499    dx_v1 = intersect_x - v1.T[0][:, np.newaxis]
 500    dx_v2 = intersect_x - v2.T[0][:, np.newaxis]
 501    dy_v1 = intersect_y - v1.T[1][:, np.newaxis]
 502    dy_v2 = intersect_y - v2.T[1][:, np.newaxis]
 503    dx = dx_v1 * dx_v2
 504    dy = dy_v1 * dy_v2
 505    dist_x = skel_x[np.newaxis, :] - intersect_x
 506    dist_y = skel_y[np.newaxis, :] - intersect_y
 507
 508    non_boundry_points = np.where(np.logical_and(dy > 0, dx > 0))
 509    dist_matrix = np.sqrt(dist_x**2 + dist_y**2)
 510    dist_matrix[non_boundry_points] = np.inf
 511    nearest_id_x = np.argsort(dist_matrix, axis=0)[:2]
 512    nearest_id_y = np.linspace(
 513        0, dist_matrix.shape[1] - 1, dist_matrix.shape[1]
 514    ).astype(int)
 515    dists = (
 516        dist_matrix[nearest_id_x[0], nearest_id_y]
 517        + dist_matrix[nearest_id_x[1], nearest_id_y]
 518    )
 519    return np.concatenate([[0], dists, [0]])
 520
 521
 522def contour2mesh(contour, cropped_mask):
 523    try:
 524        skeleton = extract_skeleton(cropped_mask)
 525    except Exception:
 526        bactoscoop_logger.debug("Skeletonization failed")
 527        skeleton = []
 528    if np.any(skeleton):
 529        try:
 530            extended_skeleton, pole1, pole2 = extend_skeleton(
 531                skeleton, contour, find_pole1=True, find_pole2=True
 532            )
 533
 534            length = line_length(extended_skeleton)
 535            width = direct_intersect_distance(extended_skeleton, contour)
 536            if np.any(width):
 537                l1, l2, profile_mesh, midline = straighten_by_orthogonal_lines(
 538                    contour, extended_skeleton, length, width, unit_micron=0.5
 539                )
 540                result, l1, l2 = add_poles(l1, l2, pole1, pole2)
 541                return contour, result, midline
 542        except Exception:
 543            bactoscoop_logger.debug("width calculations for mesh creation failed")
 544            width = []
 545    return [], [], []
 546
 547
 548def draw_mask(contour, image):
 549    dilated_contour = expand_contour(contour, 0.5)
 550    mask_dilated = polygon2mask(image.shape, dilated_contour)
 551    return mask_dilated
 552
 553
 554def crop_image(image=None, contour=None, mask_to_crop=None, phase_img=None, pad=10):
 555    if contour is not None:
 556        cropped_contour = contour.copy()
 557        contour = contour.astype(int)
 558        x, y, w, h = crop(contour, pad, image)
 559        cropped_img = image[x : x + w, y : y + h]
 560
 561        if phase_img is not None:
 562            cropped_phase_img = phase_img[x : x + w, y : y + h]
 563            # Perform registration between cropped_img and cropped_phase_img
 564            shift, error, _diff = phase_cross_correlation(
 565                cropped_phase_img, cropped_img, upsample_factor=20
 566            )
 567            if max(np.abs(shift)) <= 15:
 568
 569                shift = np.array(shift)  # Ensure shift is in correct format
 570                cropped_img = shift_image(cropped_img, shift)
 571            else:
 572                cropped_img = image[x : x + w, y : y + h]
 573        cropped_mask = None
 574        cropped_contour -= [x, y]
 575
 576    elif mask_to_crop is not None:
 577        x, y, w, h = crop(mask_to_crop, pad, mask_to_crop, xy_coordinates=True)
 578        cropped_mask = mask_to_crop[y : y + h, x : x + w]
 579        cropped_img = None
 580        cropped_contour = contour
 581
 582    return cropped_img, cropped_mask, cropped_contour, x, y
 583
 584
 585def crop(to_be_cropped, pad, image, xy_coordinates=False):
 586
 587    padding = pad  # Default padding value
 588    x, y, w, h = cv.boundingRect(
 589        to_be_cropped
 590    )  # Get the smallest bounding rectangle of the contour
 591
 592    # Contours use internal (row, column) coordinates; mask bounding boxes
 593    # returned by OpenCV use (column, row) coordinates.
 594    x_limit, y_limit = (
 595        (image.shape[1], image.shape[0])
 596        if xy_coordinates
 597        else (image.shape[0], image.shape[1])
 598    )
 599    x = np.clip(x - padding, 0, x_limit)
 600    y = np.clip(y - padding, 0, y_limit)
 601    w = np.clip(w + 2 * padding, 0, x_limit - x)
 602    h = np.clip(h + 2 * padding, 0, y_limit - y)
 603    return x, y, w, h
 604
 605
 606def get_object_contours(mask, smoothing=0.1):  # x_offset, y_offset,
 607    contours, _ = cv.findContours(mask, cv.RETR_EXTERNAL, cv.CHAIN_APPROX_NONE)
 608    interpolated_contours = []
 609
 610    for contour in contours:
 611        contour = np.array(contour)
 612        contourx = contour[:, 0, 1]
 613        contoury = contour[:, 0, 0]
 614        s = int(len(contourx) * smoothing)
 615        try:
 616            tck, u = interpolate.splprep([contourx, contoury], u=None, s=s, per=1)
 617            u_new = np.linspace(u.min(), u.max(), len(contourx))
 618            outx, outy = interpolate.splev(u_new, tck, der=0)
 619            interpolated_contour = np.array([outx, outy]).T
 620        except Exception as e:
 621            bactoscoop_logger.debug(f"{e}")
 622            continue
 623        interpolated_contours.append(interpolated_contour)
 624
 625    return interpolated_contours
 626
 627def maybe_reverse_orientation(contour, result, midline):
 628    if np.random.rand() < 0.5:
 629
 630        contour = contour[::-1]
 631        result = result[::-1]
 632        midline = midline[::-1]
 633    return contour, result, midline
 634
 635
 636def _collect_touching_label_pairs(label_slice_a, label_slice_b):
 637    touching = (
 638        (label_slice_a != label_slice_b)
 639        & (label_slice_a != 0)
 640        & (label_slice_b != 0)
 641    )
 642    if not np.any(touching):
 643        return np.empty((0, 2), dtype=np.int64)
 644
 645    pairs = np.stack((label_slice_a[touching], label_slice_b[touching]), axis=1)
 646    pairs = np.sort(pairs, axis=1)
 647    return np.unique(pairs, axis=0)
 648
 649
 650def get_label_neighbor_counts(labels, connectivity=1):
 651    labels = np.asarray(labels)
 652    if labels.ndim != 2:
 653        raise ValueError("labels must be a 2D labeled mask.")
 654
 655    if connectivity not in (1, 4, 2, 8):
 656        raise ValueError("connectivity must be one of 1, 4, 2, or 8.")
 657
 658    unique_labels = np.unique(labels)
 659    unique_labels = unique_labels[unique_labels != 0]
 660    neighbor_map = {int(label): set() for label in unique_labels}
 661
 662    pair_arrays = [
 663        _collect_touching_label_pairs(labels[:, :-1], labels[:, 1:]),
 664        _collect_touching_label_pairs(labels[:-1, :], labels[1:, :]),
 665    ]
 666
 667    if connectivity in (2, 8):
 668        pair_arrays.extend(
 669            [
 670                _collect_touching_label_pairs(labels[:-1, :-1], labels[1:, 1:]),
 671                _collect_touching_label_pairs(labels[:-1, 1:], labels[1:, :-1]),
 672            ]
 673        )
 674
 675    non_empty_pairs = [pairs for pairs in pair_arrays if len(pairs) > 0]
 676    if non_empty_pairs:
 677        all_pairs = np.unique(np.vstack(non_empty_pairs), axis=0)
 678        for left_label, right_label in all_pairs:
 679            left_label = int(left_label)
 680            right_label = int(right_label)
 681            neighbor_map[left_label].add(right_label)
 682            neighbor_map[right_label].add(left_label)
 683
 684    return {label: len(neighbors) for label, neighbors in neighbor_map.items()}
 685
 686
 687def filter_labels_by_neighbor_count(labels, max_neighbors, connectivity=1):
 688    if max_neighbors is None:
 689        raise ValueError("max_neighbors must be provided.")
 690
 691    labels = np.asarray(labels)
 692    if labels.ndim != 2:
 693        raise ValueError("labels must be a 2D labeled mask.")
 694
 695    neighbor_counts = get_label_neighbor_counts(labels, connectivity=connectivity)
 696    removed_labels = sorted(
 697        label for label, count in neighbor_counts.items() if count > max_neighbors
 698    )
 699
 700    filtered_labels = labels.copy()
 701    if removed_labels:
 702        filtered_labels[np.isin(filtered_labels, removed_labels)] = 0
 703
 704    remaining_labels = np.unique(filtered_labels)
 705    remaining_labels = remaining_labels[remaining_labels != 0]
 706
 707    if remaining_labels.size == 0:
 708        relabeled = np.zeros_like(filtered_labels, dtype=np.int32)
 709    else:
 710        lookup = np.zeros(int(remaining_labels.max()) + 1, dtype=np.int32)
 711        lookup[remaining_labels] = np.arange(
 712            1, remaining_labels.size + 1, dtype=np.int32
 713        )
 714        relabeled = lookup[filtered_labels]
 715
 716    stats = {
 717        "connectivity": 4 if connectivity in (1, 4) else 8,
 718        "max_neighbors": int(max_neighbors),
 719        "total_label_count": int(len(neighbor_counts)),
 720        "removed_label_count": int(len(removed_labels)),
 721        "kept_label_count": int(len(remaining_labels)),
 722        "max_neighbor_count": int(max(neighbor_counts.values(), default=0)),
 723        "mean_neighbor_count": float(
 724            np.mean(list(neighbor_counts.values())) if neighbor_counts else 0.0
 725        ),
 726        "removed_labels": removed_labels,
 727    }
 728    return relabeled, stats
 729
 730def get_cellular_mesh(masks, smoothing):
 731    meshdata = {}
 732    cell_labels = np.unique(masks)
 733    cell_labels = cell_labels[cell_labels != 0]
 734
 735    for cell in tqdm_if_verbose(cell_labels):
 736        temp_mask = np.where(masks == cell, 1, 0)
 737        temp_mask = cv.convertScaleAbs(temp_mask)
 738
 739        _, cropped_mask, _, x, y = crop_image(mask_to_crop=temp_mask)
 740
 741        cell_contours = get_object_contours(cropped_mask, smoothing=smoothing)
 742        for cn in cell_contours:
 743            contour, result, midline = contour2mesh(cn, cropped_mask)
 744            contour, result, midline = maybe_reverse_orientation(contour, result, midline)
 745            if np.any(contour):
 746
 747                contour += [y, x]
 748                result += [y, x, y, x]
 749                midline += [y, x]
 750
 751                meshdata[cell] = {
 752                    "contour": contour,
 753                    "mesh": result,
 754                    "midline": midline,
 755                }
 756    meshdataframe = pd.DataFrame.from_dict(
 757        meshdata, orient="index", columns=["contour", "mesh", "midline"]
 758    )
 759    meshdataframe.reset_index(drop=True, inplace=True)
 760    return meshdataframe
 761
 762
 763# MOMIA
 764def distance(v1, v2):
 765    # Euclidean distance of two points
 766    return np.sqrt(np.sum((np.array(v1) - np.array(v2)) ** 2))
 767
 768
 769# MOMIA
 770def measure_width(extended_skeleton, smoothed_contour):
 771    length = line_length(extended_skeleton)
 772    d_perp = unit_perpendicular_vector(extended_skeleton, closed=False)
 773    width_list = []
 774    for i in range(1, len(extended_skeleton) - 1):
 775        xy = line_contour_intersection(
 776            extended_skeleton[i], d_perp[i] + extended_skeleton[i], smoothed_contour
 777        )
 778        coords = np.average(xy, axis=0)
 779        if (len(xy) == 2) and (np.isnan(coords).sum() == 0):
 780            width_list.append(distance(xy[0], xy[1]))
 781        else:
 782            raise ValueError(
 783                "Error encountered while computing line intersection points!"
 784            )
 785    return np.array([0] + width_list + [0]), length
 786
 787
 788def get_cell_pairs(pole_1, pole_2, cell_ID, maxdist=None):
 789    dm11 = distance_matrix(pole_1, pole_1)
 790    dm22 = distance_matrix(pole_2, pole_2)
 791    dm12 = distance_matrix(pole_1, pole_2)
 792    for i in cell_ID:
 793        dm11 = np.insert(
 794            dm11, i - 1, values=np.zeros((1, dm11.shape[1]), dtype=int), axis=0
 795        )
 796        dm11 = np.insert(dm11, i - 1, values=np.zeros(dm11.shape[0], dtype=int), axis=1)
 797        dm22 = np.insert(
 798            dm22, i - 1, values=np.zeros((1, dm22.shape[1]), dtype=int), axis=0
 799        )
 800        dm22 = np.insert(dm22, i - 1, values=np.zeros(dm22.shape[0], dtype=int), axis=1)
 801        dm12 = np.insert(
 802            dm12, i - 1, values=np.zeros((1, dm12.shape[1]), dtype=int), axis=0
 803        )
 804        dm12 = np.insert(dm12, i - 1, values=np.zeros(dm12.shape[0], dtype=int), axis=1)
 805    if maxdist != None:
 806        maxdistance = maxdist
 807        neighboring_cells_dm11 = np.argwhere(
 808            np.logical_and(dm11 > 0, dm11 <= maxdistance)
 809        )
 810        neighboring_cells_dm22 = np.argwhere(
 811            np.logical_and(dm22 > 0, dm22 <= maxdistance)
 812        )
 813        neighboring_cells_dm12 = np.argwhere(
 814            np.logical_and(dm12 > 0, dm12 <= maxdistance)
 815        )
 816        nc_concat = np.concatenate(
 817            (neighboring_cells_dm11, neighboring_cells_dm22, neighboring_cells_dm12),
 818            axis=0,
 819        )
 820        unique_nc1 = np.unique(nc_concat, axis=0)
 821        unique_nc2 = unique_nc1[unique_nc1[:, 0] != unique_nc1[:, 1]]
 822    else:
 823        # get index list of cell couples. Loop decides the distance for when more than 2 cells from couples with other cells
 824        maxdistance = 1
 825        while True:  # or maxdist = 8
 826            neighboring_cells_dm11 = np.argwhere(
 827                np.logical_and(dm11 > 0, dm11 <= maxdistance)
 828            )
 829            neighboring_cells_dm22 = np.argwhere(
 830                np.logical_and(dm22 > 0, dm22 <= maxdistance)
 831            )
 832            neighboring_cells_dm12 = np.argwhere(
 833                np.logical_and(dm12 > 0, dm12 <= maxdistance)
 834            )
 835            nc_concat = np.concatenate(
 836                (
 837                    neighboring_cells_dm11,
 838                    neighboring_cells_dm22,
 839                    neighboring_cells_dm12,
 840                ),
 841                axis=0,
 842            )
 843            unique_nc1 = np.unique(nc_concat, axis=0)
 844            unique_nc2 = unique_nc1[unique_nc1[:, 0] != unique_nc1[:, 1]]
 845            unique_values, inverse_indices = np.unique(unique_nc2, return_inverse=True)
 846            counts = np.bincount(inverse_indices)
 847            # Check if the current neighboring cells are already in the set of unique cells
 848            if counts == []:
 849                print("No neighboring cells present in the image")
 850                break
 851            try:
 852                if np.max(counts) > 2:
 853                    print(
 854                        f"Found triple neighboring cells after {maxdistance} distance units"
 855                    )
 856                    maxdistance = maxdistance
 857                    break
 858            except ValueError:
 859                pass
 860
 861            # Increment the maximum distance to search for
 862            maxdistance += 1
 863            if maxdistance > 10:
 864                print("pole distance to reach 4 stitched cells exceeds 20")
 865                break
 866            if counts == []:
 867                unique_nc2 = []
 868            else:
 869                neighboring_cells_dm11 = np.argwhere(
 870                    np.logical_and(dm11 > 0, dm11 <= maxdistance)
 871                )
 872                neighboring_cells_dm22 = np.argwhere(
 873                    np.logical_and(dm22 > 0, dm22 <= maxdistance)
 874                )
 875                neighboring_cells_dm12 = np.argwhere(
 876                    np.logical_and(dm12 > 0, dm12 <= maxdistance)
 877                )
 878                nc_concat = np.concatenate(
 879                    (
 880                        neighboring_cells_dm11,
 881                        neighboring_cells_dm22,
 882                        neighboring_cells_dm12,
 883                    ),
 884                    axis=0,
 885                )
 886                unique_nc1 = np.unique(nc_concat, axis=0)
 887                unique_nc2 = unique_nc1[unique_nc1[:, 0] != unique_nc1[:, 1]]
 888    return unique_nc2
 889
 890
 891def mesh2contour(x1, y1, x2, y2):
 892    x2f = np.flip(x2)
 893    y2f = np.flip(y2)
 894    # Concatenate the x and y coordinates
 895    xspline = np.concatenate((x2f[1:], x1[1:]))
 896    yspline = np.concatenate((y2f[1:], y1[1:]))
 897
 898    tck, u = interpolate.splprep(np.array([xspline, yspline]), k=3, s=2, per=1)
 899    u_new = np.linspace(u.min(), u.max(), 200)
 900    outx, outy = interpolate.splev(u_new, tck)
 901
 902    return np.array([outx, outy]).T
 903
 904
 905def split_point(x1, y1, x2, y2, ctpos):
 906    dx = x2 - x1
 907    dy = y2 - y1
 908    dist = np.sqrt(dx**2 + dy**2)
 909    # ctpos is the index at which the cell is most constricted and where the algorith will split the cell
 910    xc1 = x1[ctpos]
 911    yc1 = y1[ctpos]
 912    xc2 = x2[ctpos]
 913    yc2 = y2[ctpos]
 914    newpole_x = (xc1 + xc2) / 2
 915    newpole_y = (yc1 + yc2) / 2
 916
 917    xt1, yt1 = x1[ctpos + 1], y1[ctpos + 1]
 918    distt = dist[ctpos + 1]
 919    # Use indexing to get the x and y coordinates separately
 920    xt13, yt13 = (
 921        np.array([xt1, yt1])
 922        + (distt / 3) * np.array([dx[ctpos + 1], dy[ctpos + 1]]) / distt
 923    )
 924    xt23, yt23 = (
 925        np.array([xt1, yt1])
 926        + (distt * (2 / 3)) * np.array([dx[ctpos + 1], dy[ctpos + 1]]) / distt
 927    )
 928
 929    xs1, ys1 = x1[ctpos - 1], y1[ctpos - 1]
 930    dists = dist[ctpos - 1]
 931
 932    # Use indexing to get the x and y coordinates separately
 933    xs13, ys13 = (
 934        np.array([xs1, ys1])
 935        + (dists / 3) * np.array([dx[ctpos - 1], dy[ctpos - 1]]) / dists
 936    )
 937    xs23, ys23 = (
 938        np.array([xs1, ys1])
 939        + (dists * (2 / 3)) * np.array([dx[ctpos - 1], dy[ctpos - 1]]) / dists
 940    )
 941
 942    return (
 943        newpole_x,
 944        newpole_y,
 945        np.array([[xt13, yt13, xt23, yt23]]),
 946        np.array([[xs13, ys13, xs23, ys23]]),
 947    )
 948
 949
 950# calculates the constriction degree in relative and absolute value,
 951# and the relative position along the length of the cell
 952# absolute constr degree needs to be multiplied by the width
 953def constr_degree_single_cell_min(
 954    intensity,
 955    new_step_length,
 956    upper_limit=0.75,
 957    lower_limit=0.25,
 958    interp_factor=10,
 959):
 960    """
 961    Compute constriction degree and return both coarse and fine minima positions.
 962
 963    Parameters
 964    ----------
 965    intensity : array-like
 966        1D intensity profile.
 967    new_step_length : array-like
 968        Currently unused here, kept for API compatibility.
 969    upper_limit : float, optional
 970        Upper bound of region to search, as fraction of profile length.
 971    lower_limit : float, optional
 972        Lower bound of region to search, as fraction of profile length.
 973    interp_factor : int, optional
 974        Upsampling factor for fine-scale interpolation.
 975
 976    Returns
 977    -------
 978    minsize : float
 979        Relative constriction degree.
 980    ctposr : float
 981        Relative position of the constriction in [0, 1].
 982    minsizeabs : float
 983        Absolute constriction depth.
 984    ctpos_coarse : int or list
 985        Constriction position in original intensity index space.
 986    ctpos_fine : int or list
 987        Constriction position in interpolated fine index space.
 988    """
 989    minsize = 0
 990    minsizeabs = 0
 991    ctpos_coarse = []
 992    ctpos_fine = []
 993    ctposr = np.nan
 994
 995    intensity = np.asarray(intensity).squeeze()
 996
 997    if intensity.ndim != 1 or len(intensity) < 3:
 998        return minsize, ctposr, minsizeabs, ctpos_coarse, ctpos_fine
 999
1000    # Original and fine coordinate grids
1001    x = np.linspace(0, 1, len(intensity))
1002    x_fine = np.linspace(0, 1, len(intensity) * interp_factor)
1003
1004    try:
1005        f_interp = interpolate.interp1d(
1006            x, intensity, kind="cubic", bounds_error=False, fill_value="extrapolate"
1007        )
1008        intensity_fine = f_interp(x_fine)
1009    except Exception:
1010        return minsize, ctposr, minsizeabs, ctpos_coarse, ctpos_fine
1011
1012    # Search window in fine space
1013    start_idx = int(lower_limit * len(x_fine))
1014    end_idx = int(upper_limit * len(x_fine))
1015
1016    # Keep valid bounds
1017    start_idx = max(0, min(start_idx, len(x_fine) - 1))
1018    end_idx = max(start_idx + 1, min(end_idx, len(x_fine)))
1019
1020    intensity_section = intensity_fine[start_idx:end_idx]
1021
1022    try:
1023        minima = np.concatenate(
1024            (
1025                [False],
1026                (intensity_section[1:-1] < intensity_section[:-2])
1027                & (intensity_section[1:-1] < intensity_section[2:]),
1028                [False],
1029            )
1030        )
1031    except Exception:
1032        return minsize, ctposr, minsizeabs, ctpos_coarse, ctpos_fine
1033
1034    if (not np.any(minima)) or np.sum(intensity_section) == 0:
1035        return minsize, ctposr, minsizeabs, ctpos_coarse, ctpos_fine
1036
1037    index, dh, dhi, hgt = identify_minima(intensity_section, minima)
1038    fix = np.argmax(dh)
1039
1040    minsizeabs = dhi[fix]
1041    hgt_fix = hgt[fix]
1042
1043    if hgt_fix != 0:
1044        minsize = minsizeabs / hgt_fix
1045
1046    # Fine-space global index
1047    ctpos_fine = int(start_idx + index[fix])
1048
1049    # Relative position [0, 1]
1050    ctposr = float(x_fine[ctpos_fine])
1051
1052    # Map fine position back to original/coarse intensity index
1053    ctpos_coarse = int(round(ctposr * (len(intensity) - 1)))
1054    ctpos_coarse = max(0, min(ctpos_coarse, len(intensity) - 1))
1055
1056    return minsize, ctposr, minsizeabs, ctpos_coarse, ctpos_fine
1057
1058
1059def interp2d(image):
1060    ff = interpolate.RectBivariateSpline(
1061        range(image.shape[0]), range(image.shape[1]), image, kx=1, ky=1
1062    )
1063    return ff
1064
1065
1066def get_image_for_frame(df_nr, images):
1067    return images[df_nr]
1068
1069
1070def get_cell_mask(contour, midline, image):
1071    cropped_img, cropped_mask, cropped_contour, x, y = crop_image(
1072        image=image, contour=contour, mask_to_crop=None
1073    )
1074    cropped_midline = midline.copy()
1075    cropped_midline -= [x, y]
1076    mask = draw_mask(cropped_contour, cropped_img)
1077    return mask, cropped_img, cropped_contour, cropped_midline, x, y
1078
1079
1080# %% Object detection and mesh creation functions
1081def keep_significant_masks(
1082    nucleoid_mask, cropped_mask, min_overlap_ratio=0.01, max_external_ratio=0.1
1083):
1084    # Find connected components (blobs) in the nucleoid_mask
1085    num_labels, labels, stats, _ = cv.connectedComponentsWithStats(
1086        nucleoid_mask.astype(np.uint8)
1087    )
1088
1089    # Create a mask to keep blobs with significant overlap
1090    significant_mask = np.zeros_like(nucleoid_mask, dtype=np.uint8)
1091
1092    # Get the total area of the cropped_mask
1093    total_cropped_mask_area = np.sum(cropped_mask)
1094
1095    for label in range(1, num_labels):  # Skip label 0 as it represents the background
1096        # Extract the blob region for the current label
1097        blob_mask = (labels == label).astype(np.uint8)
1098
1099        # Calculate the overlap with the cropped_mask
1100        overlap_area = np.sum(np.logical_and(blob_mask, cropped_mask))
1101
1102        # Calculate the overlap ratio
1103        overlap_ratio = overlap_area / total_cropped_mask_area
1104
1105        # Calculate the external ratio (portion outside of cropped_mask)
1106        external_area = np.sum(np.logical_and(blob_mask, ~cropped_mask))
1107        external_ratio = external_area / np.sum(blob_mask)
1108        # Keep blobs that have a significant overlap with the cropped_mask
1109        # and don't exceed the maximum external ratio
1110        if overlap_ratio >= min_overlap_ratio and external_ratio <= max_external_ratio:
1111            significant_mask |= blob_mask
1112
1113    return significant_mask
1114
1115
1116def get_object_masks(contour, object_contours, object_midlines, object_meshes, signal):
1117
1118    masks = []
1119    cropped_img, cropped_mask, cropped_contour, x, y = crop_image(
1120        image=signal, contour=contour, mask_to_crop=None
1121    )
1122    cropped_object_contours, cropped_object_midlines, cropped_object_meshes = (
1123        get_cropped_object_data(object_contours, object_midlines, object_meshes, x, y)
1124    )
1125    for cropped_object_contour in cropped_object_contours:
1126
1127        temp_masks = draw_mask(cropped_object_contour, cropped_img)
1128        masks.append(temp_masks)
1129
1130    return (
1131        masks,
1132        cropped_img,
1133        cropped_object_contours,
1134        cropped_object_midlines,
1135        cropped_object_meshes,
1136        x,
1137        y,
1138    )
1139
1140
1141def get_subcellular_objects(
1142    contour,
1143    signal,
1144    image,
1145    cell_id,
1146    smoothing,
1147    log_sigma,
1148    kernel_width,
1149    min_overlap_ratio=0.01,
1150    max_external_ratio=0.1,
1151):
1152
1153    cropped_img, _, cropped_contour, x_offset, y_offset = crop_image(
1154        image=signal, contour=contour, mask_to_crop=None, phase_img=image
1155    )
1156
1157
1158    cropped_mask = draw_mask(cropped_contour, cropped_img)
1159    bgr = median_background(cropped_img, cropped_mask)
1160    cropped_img_bgr = np.maximum(cropped_img - bgr, 0)
1161
1162    # Apply Laplacian of Gaussian (LoG) filter to the cropped_img
1163    log_filtered_img = gaussian_laplace(cropped_img_bgr, sigma=log_sigma)
1164
1165    threshold_value = threshold_otsu(log_filtered_img)
1166
1167    object_mask = log_filtered_img < threshold_value
1168
1169    kernel = np.ones(
1170        (kernel_width, kernel_width), np.uint8
1171    )  # You can adjust the kernel size as needed
1172    object_mask = cv.dilate(object_mask.astype(np.uint8), kernel, iterations=1)
1173
1174    object_mask = keep_significant_masks(
1175        object_mask, cropped_mask, min_overlap_ratio, max_external_ratio
1176    )
1177
1178    if np.any(object_mask == 1):
1179        object_contours = get_object_contours(
1180            object_mask, smoothing
1181        )  # , x_offset, y_offset
1182    else:
1183        object_contours = []
1184
1185    return object_contours, cropped_img, object_mask, x_offset, y_offset
1186
1187
1188def get_object_mesh(
1189    contour,
1190    signal,
1191    image,
1192    cell_id,
1193    smoothing,
1194    log_sigma,
1195    kernel_width,
1196    min_overlap_ratio,
1197    max_external_ratio,
1198):
1199    meshdata = {
1200        "cell_id": cell_id,
1201        "object_contour": [],
1202        "cropped_object_contour": [],
1203        "object_mesh": [],
1204        "object_midline": [],
1205        "mesh_construction_errors": [],
1206    }
1207    object_contours, cropped_img, object_mask, x, y = get_subcellular_objects(
1208        contour,
1209        signal,
1210        image,
1211        cell_id,
1212        smoothing,
1213        log_sigma,
1214        kernel_width,
1215        min_overlap_ratio,
1216        max_external_ratio,
1217    )
1218
1219    if object_contours:
1220
1221        for object_index, cn in enumerate(object_contours):
1222            meshdata["object_contour"].append(cn + [x, y])
1223            meshdata["cropped_object_contour"].append(cn)
1224
1225            try:
1226                temp_mask = draw_mask(cn, object_mask)
1227                _, result, midline = contour2mesh(cn, temp_mask)
1228                if np.any(result) and result.shape[0] < 800:
1229                    result += [x, y, x, y]
1230                    midline += [x, y]
1231                    meshdata["object_mesh"].append(result)
1232                    meshdata["object_midline"].append(midline)
1233                    continue
1234            except Exception as exc:
1235                meshdata["mesh_construction_errors"].append(
1236                    {
1237                        "object_index": object_index,
1238                        "error_type": type(exc).__name__,
1239                        "error": str(exc),
1240                    }
1241                )
1242
1243            # Keep the contour and a matching placeholder for an invalid mesh.
1244            meshdata["object_mesh"].append(np.array([]))
1245            meshdata["object_midline"].append(np.array([]))
1246
1247    return meshdata
1248
1249
1250# %% File readers/handlers
1251from natsort import natsorted
1252import re
1253
1254
1255def image_field_key(filename, suffix="", is_mask=False):
1256    """Return a normalized acquisition/field key from a TIFF filename.
1257
1258    Channel suffixes such as ``C1``/``C2`` and mask suffixes such as
1259    ``cp_masks`` are removed so corresponding files can be matched by field.
1260    """
1261    stem = os.path.splitext(os.path.basename(os.fspath(filename)))[0]
1262    if is_mask:
1263        stem = re.sub(r"(?:[_-]?cp)?[_-]?masks?$", "", stem, flags=re.IGNORECASE)
1264        # A mask tagged for C2 must not be paired with a C1 phase image.
1265        if suffix:
1266            suffix = str(suffix)
1267            if stem.casefold().endswith(suffix.casefold()):
1268                stem = stem[:-len(suffix)].rstrip(" _-")
1269        else:
1270            stem = re.sub(r"[_-]?C\d+$", "", stem, flags=re.IGNORECASE)
1271    elif suffix:
1272        suffix = str(suffix)
1273        if stem.casefold().endswith(suffix.casefold()):
1274            stem = stem[: -len(suffix)].rstrip(" _-")
1275    else:
1276        stem = re.sub(r"[_-]?C\d+$", "", stem, flags=re.IGNORECASE)
1277    return stem.strip().casefold()
1278
1279
1280def read_channels(folder_path, images_dict, channel_list=None):
1281    if channel_list is None:
1282        # If no suffixes are provided, assume an empty list
1283        channel_list = []
1284
1285    missing_channels = []
1286    for channel in channel_list:
1287        matching_files = natsorted([
1288            f
1289            for f in os.listdir(folder_path)
1290            if f.casefold().endswith(f"{channel}.tif".casefold())
1291            or f.casefold().endswith(f"{channel}.tiff".casefold())
1292        ])
1293
1294        if matching_files:
1295            images_dict[channel] = [
1296                tifffile.imread(os.path.join(folder_path, tiff_file))
1297                for tiff_file in matching_files
1298            ]
1299            bactoscoop_logger.info(
1300                f"Images with specified suffixes {channel} found in folder: {folder_path}"
1301            )
1302        else:
1303            missing_channels.append(channel)
1304    if missing_channels:
1305        raise ValueError(
1306            f"No images found for requested channel(s) {missing_channels} in folder: {folder_path}"
1307        )
1308
1309    return images_dict
1310
1311
1312def read_tiff_folder(folder_path, suffix="", include_paths=False):
1313    # Get a list of all TIFF files in the folder
1314    tiff_files = natsorted([
1315        f
1316        for f in os.listdir(folder_path)
1317        if f.casefold().endswith(f"{suffix}.tif".casefold())
1318        or f.casefold().endswith(f"{suffix}.tiff".casefold())
1319    ])
1320    if not tiff_files:
1321        # Raise an exception if no TIFF files are found in the folder
1322        raise ValueError("\nNo TIFF files found in folder: " + folder_path)
1323
1324    images = []
1325    file_names = []
1326    paths = [] if include_paths else None
1327
1328    for tiff_file in tiff_files:
1329        tiff_path = os.path.join(folder_path, tiff_file)
1330        image = tifffile.imread(tiff_path)
1331        images.append(image)
1332        file_names.append(tiff_file)
1333        if include_paths:
1334            paths.append(tiff_path)
1335
1336    if len(images) == 1:
1337        bactoscoop_logger.info(f"Only one TIFF file found in folder: {folder_path}")
1338        images = np.expand_dims(images[0], axis=0)
1339        file_names = [file_names[0]]
1340        if include_paths:
1341            paths = [paths[0]]
1342
1343    else:
1344        bactoscoop_logger.info(f"{len(images)} TIFF files found in folder: {folder_path}")
1345
1346    if include_paths:
1347        return images, file_names, paths
1348    else:
1349        return images, file_names
1350
1351
1352# %% Contour and mesh operations
1353def separate_meshdata(df_nr, cell_nr, data):
1354    x1 = data[df_nr]["mesh"][cell_nr][:, 0]
1355    y1 = data[df_nr]["mesh"][cell_nr][:, 1]
1356    x2 = data[df_nr]["mesh"][cell_nr][:, 2]
1357    y2 = data[df_nr]["mesh"][cell_nr][:, 3]
1358    contour = data[df_nr]["contour"][cell_nr]
1359    return x1, y1, x2, y2, contour
1360
1361
1362def separate_singleframe_meshdata(cell_id, data):
1363    row = data[data.index == cell_id].iloc[0]
1364    x1 = row["mesh"][:, 0]
1365    y1 = row["mesh"][:, 1]
1366    x2 = row["mesh"][:, 2]
1367    y2 = row["mesh"][:, 3]
1368    contour = row["contour"]
1369    midline = row["midline"]
1370    return x1, y1, x2, y2, contour, midline
1371
1372
1373def get_cropped_object_data(cnts, mdls, mshs, x, y):
1374    cropped_cnts = []
1375    cropped_mdls = []
1376    cropped_mshs = []
1377
1378    for cn, ml, msh in zip(cnts, mdls, mshs):
1379        crop_cn = cn.copy()
1380        crop_ml = ml.copy()
1381        crop_msh = msh.copy()
1382
1383        crop_cn -= [x, y]
1384        if crop_ml.size:
1385            crop_ml -= [x, y]
1386        if crop_msh.size:
1387            crop_msh -= [x, y, x, y]
1388
1389        cropped_cnts.append(crop_cn)
1390        cropped_mdls.append(crop_ml)
1391        cropped_mshs.append(crop_msh)
1392
1393    return cropped_cnts, cropped_mdls, cropped_mshs
1394
1395
1396def get_cropped_cell_data(cnt, mdl, msh, x, y):
1397    cropped_cn = cnt.copy()
1398    cropped_ml = mdl.copy()
1399    cropped_msh = msh.copy()
1400
1401    cropped_cn -= [x, y]
1402    cropped_ml -= [x, y]
1403    cropped_msh -= [x, y, x, y]
1404
1405    return cropped_cn, cropped_ml, cropped_msh
1406
1407
1408def get_uncropped_cell_data(cnt, mdl, msh, x, y):
1409    uncropped_cn = cnt.copy()
1410    uncropped_ml = mdl.copy()
1411    uncropped_msh = msh.copy()
1412
1413    uncropped_cn += [x, y]
1414    uncropped_ml += [x, y]
1415    uncropped_msh += [x, y, x, y]
1416
1417    return uncropped_cn, uncropped_ml, uncropped_msh
1418
1419
1420def mesh2midline(x1, y1, x2, y2):
1421    x = (x1 + x2) / 2
1422    y = (y1 + y2) / 2
1423    line = np.array([x, y]).T
1424    line = spline_approximation(line, n=len(x1), smooth_factor=3, closed=False)
1425    return line
1426
1427
1428def get_weighted_intprofile(intensity, width):
1429
1430    if intensity.shape[0] == width.shape[0]:
1431        return intensity * width
1432
1433    else:
1434        return []
1435
1436
1437def get_width(x1, y1, x2, y2):
1438    width_not_ordered = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
1439    return width_not_ordered
1440
1441
1442def _validate_max_daughter_cell_mesh_rows(value):
1443    if (
1444        isinstance(value, (bool, np.bool_))
1445        or not isinstance(value, (int, np.integer))
1446        or value < 4
1447    ):
1448        raise ValueError("max_daughter_cell_mesh_rows must be an integer >= 4")
1449    return int(value)
1450
1451
1452def split_mesh2mesh(x1, y1, x2, y2, max_daughter_cell_mesh_rows=800):
1453    """Rebuild a daughter, rejecting oversized resampling before allocation.
1454
1455    The limit applies to straightened rows here and to final daughter rows in
1456    Image.split_cells. Pole extension can add rows, so the minimum stays there.
1457    """
1458    max_daughter_cell_mesh_rows = _validate_max_daughter_cell_mesh_rows(
1459        max_daughter_cell_mesh_rows
1460    )
1461
1462    length = np.sum(get_step_length_no_px(x1, y1, x2, y2))
1463    mesh_spacing_px = 0.5
1464    # Use the same spacing/rounding as straighten_by_orthogonal_lines,
1465    # before contour reconstruction or its dense resampling allocations.
1466    predicted_rows = int(round(length / mesh_spacing_px)) if np.isfinite(length) else None
1467    if predicted_rows is None or predicted_rows > max_daughter_cell_mesh_rows:
1468        bactoscoop_logger.debug(
1469            "Skipping daughter before reconstruction: predicted_rows=%s, max_rows=%s",
1470            predicted_rows, max_daughter_cell_mesh_rows,
1471        )
1472        return np.empty((0, 4)), np.empty((0, 2)), np.empty((0, 2))
1473
1474    contour = mesh2contour(x1, y1, x2, y2)
1475    try:
1476        width, widthno = get_avg_width_no_px(x1, y1, x2, y2)
1477
1478        x = (x1 + x2) / 2
1479        y = (y1 + y2) / 2
1480        line = np.array([x, y]).T
1481        midline, pole1, pole2 = extend_skeleton(line[4:-4], contour)
1482        l1, l2, profile_mesh, midline = straighten_by_orthogonal_lines(
1483            contour, midline, length, width, unit_micron=mesh_spacing_px
1484        )
1485        result, l1, l2 = add_poles(l1, l2, pole1, pole2)
1486    except Exception as e:
1487        bactoscoop_logger.debug(f"Exception occurred: {e}")
1488        pass  # needs logging
1489    return result, contour, midline
1490
1491
1492# MOMIA
1493def get_profile_mesh(mesh, width, micron_unit=1):
1494    l1 = mesh[:, :2]
1495    l2 = mesh[:, 2:]
1496    sorted_width = sorted(width, reverse=True)
1497    width = sum(sorted_width[: math.floor(len(sorted_width) / 3)]) / math.floor(
1498        len(sorted_width) / 3
1499    )
1500    N_width = int(round(np.median(width) / 0.05))
1501    dl = (l2 - l1) / N_width
1502    mult_mat = np.tile(np.arange(N_width + 1), (len(l1), 1))
1503    mat_x = l1[:, 0][:, np.newaxis] + mult_mat * dl[:, 0][:, np.newaxis]
1504    mat_y = l1[:, 1][:, np.newaxis] + mult_mat * dl[:, 1][:, np.newaxis]
1505    profile_mesh = np.array([mat_x, mat_y])
1506    return profile_mesh
1507
1508
1509# MOMIA
1510def expand_contour(contour, scale=1):
1511    """
1512    enlarges contour
1513    :param contour:
1514    :param scale:
1515    :return:
1516    """
1517    area = 0.5 * np.sum(np.diff(contour[:, 0]) * (contour[:-1, 1] + contour[1:, 1]))
1518    if area < 0:
1519        # If the area is negative, flip the sign of the unit perpendicular vector
1520        dxy = unit_perpendicular_vector(contour, closed=True)
1521    else:
1522        # Otherwise, use the unit perpendicular vector as is
1523        dxy = unit_perpendicular_vector(contour, closed=True) * (-1)
1524    # dxy = unit_perpendicular_vector(contour, closed=True)
1525
1526    return contour - (scale * dxy)
1527
1528
1529def erode_contour(contour, scale=1):
1530    """
1531    shrinks contour
1532    :param contour:
1533    :param scale:
1534    :return:
1535    """
1536    area = 0.5 * np.sum(np.diff(contour[:, 0]) * (contour[:-1, 1] + contour[1:, 1]))
1537    if area < 0:
1538        # If the area is negative, flip the sign of the unit perpendicular vector
1539        dxy = unit_perpendicular_vector(contour, closed=True)
1540    else:
1541        # Otherwise, use the unit perpendicular vector as is
1542        dxy = unit_perpendicular_vector(contour, closed=True) * (-1)
1543    # dxy = unit_perpendicular_vector(contour, closed=True)
1544
1545    return contour + (scale * dxy)
1546
1547
1548### ------------------------------------------------------------------------------------------------
1549### Background subtraction
1550# calculates the average pixel value of the background region in an image,
1551# given a binary cell mask.
1552def mean_background(image, cell_mask):
1553    # Create a mask for the background region
1554    background_mask = np.logical_not(cell_mask)
1555
1556    # Calculate the average pixel value of the background
1557    # =>indexing the image array with the background_mask, which selects
1558    #  only the pixel values
1559    bgr = np.mean(image[background_mask])
1560    return bgr
1561
1562
1563def median_background(image, cell_mask):
1564    # Create a mask for the background region
1565    background_mask = np.logical_not(cell_mask)
1566
1567    # Calculate the average pixel value of the background
1568    # =>indexing the image array with the background_mask, which selects
1569    #  only the pixel values
1570    bgr = np.median(image[background_mask])
1571    return bgr
1572
1573
1574def poly_func(coords, a, b, c, d, e, f):
1575    x, y = coords
1576    return a + b * x + c * y + d * x**2 + e * x * y + f * y**2
1577
1578
1579def construct_bg_function(image, seg_mat, sampling_interval=None, plotF=False):
1580    """
1581    Reconstructs the background of an image using 2D polynomial fitting.
1582
1583    Parameters:
1584        image (ndarray): Input grayscale image.
1585        seg_mat (ndarray): Binary segmentation mask (non-zero = foreground).
1586        sampling_interval (int): Interval between samples. Default: height // 64.
1587        plotF (bool): Whether to plot the background fit.
1588
1589    Returns:
1590        bg_function (function): Background function F(x, y).
1591        bg_matrix (ndarray): Estimated background image.
1592    """
1593    H, W = image.shape
1594    if sampling_interval is None:
1595        sampling_interval = max(1, H // 64)
1596
1597    # Get background pixel coordinates using sparse sampling
1598    ys, xs = np.mgrid[0:H:sampling_interval, 0:W:sampling_interval]
1599    xs_flat = xs.ravel()
1600    ys_flat = ys.ravel()
1601    mask_flat = seg_mat[ys_flat, xs_flat] == 0
1602
1603    x_bg = xs_flat[mask_flat]
1604    y_bg = ys_flat[mask_flat]
1605    z_bg = image[y_bg, x_bg]
1606
1607    # Fit polynomial model to background samples
1608    popt, _ = curve_fit(poly_func, (x_bg, y_bg), z_bg, p0=(0, 0, 0, 0, 0, 0))
1609
1610    # Vectorized background matrix generation
1611    xx, yy = np.meshgrid(np.arange(W), np.arange(H))
1612    bg_matrix = poly_func((xx, yy), *popt)
1613
1614    # Optional plotting
1615    if plotF:
1616        plot_interval = max(1, sampling_interval)
1617        xx_sample, yy_sample = np.meshgrid(
1618            np.arange(0, W, plot_interval), np.arange(0, H, plot_interval)
1619        )
1620        zz_sample = poly_func((xx_sample, yy_sample), *popt)
1621
1622        fig = plt.figure()
1623        ax = fig.add_subplot(111, projection="3d")
1624        ax.plot_surface(xx_sample, yy_sample, zz_sample, alpha=0.5, cmap="viridis")
1625        ax.scatter(x_bg, y_bg, z_bg, color="r", label="Sampled BG")
1626        ax.set_xlabel("X")
1627        ax.set_ylabel("Y")
1628        plt.legend()
1629        plt.show()
1630
1631    # Return as function and matrix
1632    bg_function = lambda x, y: poly_func((x, y), *popt)
1633    return bg_function, bg_matrix
1634
1635
1636###  -------------------------------------------------------------------------------------------------
1637# %% Morphological features
1638def get_step_length(x1, y1, x2, y2, px):
1639    dx = x1[1:] + x2[1:] - x1[:-1] - x2[:-1]
1640    dy = y1[1:] + y2[1:] - y1[:-1] - y2[:-1]
1641    return (np.sqrt(dx**2 + dy**2) / 2) * px
1642
1643
1644def get_step_length_no_px(x1, y1, x2, y2):
1645    dx = x1[1:] + x2[1:] - x1[:-1] - x2[:-1]
1646    dy = y1[1:] + y2[1:] - y1[:-1] - y2[:-1]
1647    return np.sqrt(dx**2 + dy**2) / 2
1648
1649
1650def get_length(step_length):
1651    return np.sum(step_length)
1652
1653
1654def get_avg_width(x1, y1, x2, y2, px):
1655    width_not_ordered = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
1656    sorted_width = sorted(width_not_ordered, reverse=True)
1657    width = sum(sorted_width[: math.floor(len(sorted_width) / 3)]) / math.floor(
1658        len(sorted_width) / 3
1659    )
1660    return width * px, width_not_ordered * px
1661
1662
1663def get_avg_width_no_px(x1, y1, x2, y2):
1664    width_not_ordered = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
1665    sorted_width = sorted(width_not_ordered, reverse=True)
1666    width = sum(sorted_width[: math.floor(len(sorted_width) / 3)]) / math.floor(
1667        len(sorted_width) / 3
1668    )
1669    return width, width_not_ordered
1670
1671
1672def get_area(contour, px):
1673    poly = Polygon(contour)
1674    area = poly.area
1675    return area * px * px
1676
1677
1678def get_volume(x1, y1, x2, y2, step_length, px):
1679    d = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
1680    volume = np.trapz((np.pi * (d / 2) ** 2), dx=step_length)
1681    return volume * px * px
1682
1683
1684def get_surface_area(width_no, step_length):
1685    widths = width_no[1:]
1686    surface_areas = 2 * np.pi * (widths / 2) * step_length
1687    total_surface_area = np.sum(surface_areas)
1688    return total_surface_area
1689
1690
1691def get_surface_area_over_volume(sa, vol):
1692    return sa / vol
1693
1694
1695def get_cell_perimeter_measurements(contour, area, px):
1696    v1 = contour[:-1]
1697    v2 = contour[1:]
1698    d = v2 - v1
1699    perimeter = np.sum(np.sqrt(np.sum(d**2, axis=1))) * px
1700    circularity = (4 * np.pi * area) / (perimeter) ** 2
1701    compactness = (perimeter**2) / area
1702    sphericity = (np.pi * 1.5 * (perimeter / (2 * np.pi)) ** 1.5) / area
1703    return perimeter, circularity, compactness, sphericity
1704
1705
1706# cell width variability calculated based on the 50% highest cell widths
1707def get_cell_width_variability(width_no):
1708    sorted_width = sorted(width_no, reverse=True)
1709    half_idx = len(sorted_width) // 2
1710    half_width = sorted_width[:half_idx]
1711    width_var = np.std(half_width) / np.mean(half_width)
1712    return width_var
1713
1714
1715def get_curvature_characteristics(contour, px):
1716    dx = np.gradient(contour[:, 0] * px)
1717    dy = np.gradient(contour[:, 1] * px)
1718    d2x = np.gradient(dx)
1719    d2y = np.gradient(dy)
1720    curvature = np.abs(dx * d2y - dy * d2x) / (dx**2 + dy**2) ** 1.5
1721    max_c = np.round(np.max(curvature), 8)
1722    min_c = np.round(np.min(curvature), 8)
1723    mean_c = np.round(np.nanmean(curvature), 8)
1724    std_c = np.round(np.std(curvature), 8)
1725    return curvature, max_c, min_c, mean_c, std_c
1726
1727
1728def get_total_phaco_intensity(contour, shape, interp2d):
1729
1730    mask = polygon2mask(shape, contour)
1731    coords = np.column_stack(np.where(mask)).astype(int)
1732    values = interp2d.ev(coords[:, 0], coords[:, 1])
1733    return np.sum(values), np.max(values), np.mean(values)
1734    # Calculate the total phase intensity within the contour mask
1735
1736
1737def get_contour_intensity(contour, interp2d):
1738    data = interp2d.ev(contour.T[0], contour.T[1])
1739    return data
1740
1741
1742def find_signal_peaks(signal, maximum):
1743    peaks, _ = find_peaks(signal, prominence=0.5, height=(maximum * 0.5))
1744    return len(peaks)
1745
1746
1747def measure_contour_variability(signal):
1748    extra_contour_intensities = np.concatenate([signal, signal[:10]])
1749    contour_intensities_variability = np.array(
1750        [
1751            np.std(extra_contour_intensities[ss : ss + 10])
1752            for ss in range(1, len(extra_contour_intensities) - 10)
1753        ]
1754    )
1755    return contour_intensities_variability
1756
1757
1758def get_kurtosis(signal):
1759    return kurtosis(signal)
1760
1761
1762def get_skew(signal):
1763    return skew(signal)
1764
1765
1766def sinuosity(midline):
1767    end_to_end_dist = distance(midline[0], midline[-1])
1768    if end_to_end_dist <= 1e-9:
1769        raise ValueError("Unable to calculate sinuosity for a closed contour.")
1770
1771    length = line_length(midline)
1772    sinuosity = round(length / end_to_end_dist, 3)
1773
1774    if sinuosity < 1:
1775        sinuosity = 1.0
1776
1777    return sinuosity
1778
1779
1780def get_area_asymmetry(step_length, width_no):
1781    result = [step * width for step, width in zip(step_length, width_no)]
1782
1783    # Calculate the two areas
1784    area1 = sum(result[: len(result) // 2])
1785    area2 = sum(result[len(result) // 2 :])
1786
1787    if area1 == area2:
1788        return 1.0
1789    # Determine which area is smaller and which is larger
1790    smaller_area = min(area1, area2)
1791    larger_area = max(area1, area2)
1792
1793    # Calculate the ratio of the smaller area over the larger area
1794    asymmetry_ratio = smaller_area / larger_area
1795
1796    return asymmetry_ratio
1797
1798
1799#  ------------------------------------------------------------------------------------------------
1800# %% Object features (multiple contours)
1801def get_object_params(object_contours, px):
1802    length_list, width_list, box_list, x_y_list = [], [], [], []
1803
1804    for object_contour in object_contours:
1805        object_contour = object_contour.astype(np.float32)
1806        rect = cv.minAreaRect(object_contour)
1807        box = cv.boxPoints(rect).tolist()
1808        x_y, (width, length), _ = rect
1809        x_y_list.append(x_y)
1810        box_list.append(box)
1811        length_list.append(max(width, length) * px)
1812        width_list.append(min(width, length) * px)
1813
1814    total_length = sum(length_list)
1815    average_width = sum(width_list) / len(width_list)
1816
1817    return total_length, average_width
1818
1819
1820def get_object_width_variability(widths_no):
1821
1822    width_var_list = []
1823
1824    for width_no in widths_no:
1825        width_var = get_cell_width_variability(width_no)
1826        width_var_list.append(width_var)
1827
1828    avg_width_var = [np.nan] if not width_var_list else np.mean(width_var_list)
1829
1830    return width_var_list, avg_width_var
1831
1832
1833def get_mesh_coordinates(mesh):
1834    x1 = mesh[:, 0]
1835    y1 = mesh[:, 1]
1836    x2 = mesh[:, 2]
1837    y2 = mesh[:, 3]
1838    return x1, y1, x2, y2
1839
1840
1841def get_object_rect_length(nucleoid_contours, px):
1842    rect_length_list = []
1843    rect_width_list = []
1844
1845    for nucleoid_contour in nucleoid_contours:
1846        nucleoid_contour = nucleoid_contour.astype(np.float32)
1847
1848        x_y, width_height, angle_of_rotation = cv.minAreaRect(nucleoid_contour)
1849
1850        length = max(width_height) * px
1851        rect_length_list.append(length)
1852        width = min(width_height) * px
1853        rect_width_list.append(width)
1854
1855    total_rect_length = sum(rect_length_list)
1856    # Calculate average width, handling division by zero
1857    average_rect_width = [np.nan] if not rect_width_list else np.mean(rect_width_list)
1858
1859    return rect_length_list, rect_width_list, total_rect_length, average_rect_width
1860
1861
1862def _max_feret_diameter_px(coords):
1863    """Exact point-set diameter with bounded pairwise-distance memory."""
1864    if len(coords) < 2:
1865        return 0.0
1866    if len(coords) > 2:
1867        try:
1868            vertices = coords[spatial.ConvexHull(coords).vertices]
1869        except spatial.QhullError:
1870            # A one-pixel-wide, collinear mask has no 2D convex hull.
1871            axis = int(np.argmax(np.ptp(coords, axis=0)))
1872            vertices = coords[
1873                [np.argmin(coords[:, axis]), np.argmax(coords[:, axis])]
1874            ]
1875    else:
1876        vertices = coords
1877
1878    maximum = 0.0
1879    block_size = 256
1880    for start in range(0, len(vertices), block_size):
1881        first = vertices[start : start + block_size]
1882        for second_start in range(start, len(vertices), block_size):
1883            second = vertices[second_start : second_start + block_size]
1884            maximum = max(
1885                maximum,
1886                float(np.max(spatial.distance.cdist(first, second))),
1887            )
1888    return maximum
1889
1890
1891def get_additional_regionprops_features(mask, px):
1892    mask = mask.astype(bool)
1893    props = regionprops(mask.astype(int))[0]
1894    # Calculate Feret diameters
1895    coords = np.column_stack(np.where(mask))
1896    max_feret_diameter = _max_feret_diameter_px(coords) * px
1897
1898    # Calculate radii
1899    center = props.centroid
1900    y, x = np.nonzero(mask)
1901    distances2 = np.sqrt((x - center[1]) ** 2 + (y - center[0]) ** 2) * px
1902    max_radius = np.max(distances2)
1903    mean_radius = np.mean(distances2)
1904    median_radius = np.median(distances2)
1905
1906    # Create features dictionary
1907    features = {
1908        "F_MAX_FERET_DIAMETER": max_feret_diameter,
1909        "F_EQUIVALENT_DIAMETER": props.equivalent_diameter * px,
1910        "F_MAXIMUM_RADIUS": max_radius,
1911        "F_MEAN_RADIUS": mean_radius,
1912        "F_MEDIAN_RADIUS": median_radius,
1913        "F_EXTENT": props.extent,
1914    }
1915
1916    # Add moments and other properties
1917    moment_properties = [
1918        "moments",
1919        "moments_central",
1920        "moments_normalized",
1921        "moments_hu",
1922        "inertia_tensor",
1923        "inertia_tensor_eigvals",
1924    ]
1925
1926    for prop in moment_properties:
1927        if prop in ["moments_hu", "inertia_tensor_eigvals"]:
1928            for i, value in enumerate(getattr(props, prop)):
1929                features[f"{prop}_{i}"] = value
1930        else:
1931            moments = getattr(props, prop)
1932            for i in range(moments.shape[0]):
1933                for j in range(moments.shape[1]):
1934                    # Skip moments_normalized for indices (0,1), (0,0), and (1,0)
1935                    if prop == "moments_normalized" and (
1936                        i == 0 and (j == 1 or j == 0) or i == 1 and j == 0
1937                    ):
1938                        continue
1939                    features[f"{prop}_{i}_{j}"] = moments[i, j]
1940
1941    return features
1942
1943
1944def get_object_avg_width(nucleoid_masks, px):
1945
1946    nucleoid_avg_width_list = []
1947    width_not_ordered_list = []
1948
1949    for nucleoid_mask in nucleoid_masks:
1950
1951        # Convert nucleoid_mask to 8-bit unsigned integer type
1952        nucleoid_mask = nucleoid_mask.astype(np.uint8)
1953        # Ensure both arrays have the same data type
1954        distance = cv.distanceTransform(
1955            nucleoid_mask, distanceType=cv.DIST_L2, maskSize=5
1956        ).astype(np.float64)
1957        # Extract values at skeleton locations
1958        skeleton = skeletonize(nucleoid_mask)
1959
1960        width = distance[skeleton] * 2 * px
1961        width_not_ordered_list.append(width)
1962        nucleoid_avg_width_list.append(np.mean(width))
1963
1964    return np.mean(nucleoid_avg_width_list), width_not_ordered_list
1965
1966
1967def get_object_aspect_ratio(rect_length_lists, rect_width_lists):
1968
1969    aspect_ratio_list = []
1970    # wrong: has to be length_list/width_list
1971    for length_list, width_list in zip(rect_length_lists, rect_width_lists):
1972        aspect_ratio = length_list / width_list
1973        aspect_ratio_list.append(aspect_ratio)
1974    avg_aspect_ratio = [np.nan] if not aspect_ratio_list else np.mean(aspect_ratio_list)
1975
1976    return aspect_ratio_list, avg_aspect_ratio
1977
1978
1979def get_object_area(contours, px):
1980
1981    area_list = []
1982    for contour in contours:
1983
1984        poly = Polygon(contour)
1985        area = poly.area
1986        area_list.append(area * px * px)
1987
1988    total_area = np.sum(area_list)
1989    return area_list, total_area
1990
1991
1992def get_object_curvature_characteristics(contours, px):
1993    curvatures = []
1994    max_c_list = []
1995    min_c_list = []
1996    mean_c_list = []
1997    std_c_list = []
1998
1999    for contour in contours:
2000        # Calculate gradients of x and y coordinates
2001        curvature, max_c, min_c, mean_c, std_c = get_curvature_characteristics(
2002            contour, px
2003        )
2004        curvatures.append(curvature)
2005        max_c_list.append(max_c)
2006        min_c_list.append(min_c)
2007        mean_c_list.append(mean_c)
2008        std_c_list.append(std_c)
2009
2010    avg_mean_c = [np.nan] if not mean_c_list else np.mean(mean_c_list)
2011    avg_std_c = [np.nan] if not std_c_list else np.mean(std_c_list)
2012
2013    return (
2014        curvatures,
2015        max_c_list,
2016        min_c_list,
2017        mean_c_list,
2018        std_c_list,
2019        avg_mean_c,
2020        avg_std_c,
2021    )
2022
2023
2024def get_object_perimeter_measurements(contours, areas, px):
2025    perimeter_list = []
2026    circularity_list = []
2027    compactness_list = []
2028    sphericity_list = []
2029
2030    for contour, area in zip(contours, areas):
2031        perimeter, circularity, compactness, sphericity = (
2032            get_cell_perimeter_measurements(contour, area, px)
2033        )
2034
2035        perimeter_list.append(perimeter)
2036        circularity_list.append(circularity)
2037        compactness_list.append(compactness)
2038        sphericity_list.append(sphericity)
2039
2040    avg_perimeter = [np.nan] if not perimeter_list else np.mean(perimeter_list)
2041    avg_circularity = [np.nan] if not circularity_list else np.mean(circularity_list)
2042    avg_compactness = [np.nan] if not compactness_list else np.mean(compactness_list)
2043    avg_sphericity = [np.nan] if not sphericity_list else np.mean(sphericity_list)
2044
2045    return (
2046        perimeter_list,
2047        circularity_list,
2048        compactness_list,
2049        sphericity_list,
2050        avg_perimeter,
2051        avg_circularity,
2052        avg_compactness,
2053        avg_sphericity,
2054    )
2055
2056
2057def get_cell_convexity(mask):
2058    labels = label(mask)
2059
2060    properties = regionprops(labels)
2061    prop = properties[0]
2062
2063    image_perimeter = prop.perimeter
2064    image_eccentricity = prop.eccentricity
2065    image_solidity = prop.solidity
2066    convex_image_perimeter = regionprops(prop.convex_image * 1)[0].perimeter
2067    convexity = round(convex_image_perimeter / image_perimeter, 3)
2068
2069    return image_eccentricity, image_solidity, convexity
2070
2071
2072def get_object_convexity(cropped_masks):
2073
2074    convexity_list = []
2075    eccentricity_list = []
2076    solidity_list = []
2077
2078    for nucleoid_mask in cropped_masks:
2079
2080        ecc, sol, conv = get_cell_convexity(nucleoid_mask)
2081
2082        convexity_list.append(conv)
2083        eccentricity_list.append(ecc)
2084        solidity_list.append(sol)
2085
2086        avg_convexity = [np.nan] if not convexity_list else np.mean(convexity_list)
2087        avg_eccentricity = (
2088            [np.nan] if not eccentricity_list else np.mean(eccentricity_list)
2089        )
2090        avg_solidity = [np.nan] if not solidity_list else np.mean(solidity_list)
2091
2092    return (
2093        convexity_list,
2094        eccentricity_list,
2095        solidity_list,
2096        avg_convexity,
2097        avg_eccentricity,
2098        avg_solidity,
2099    )
2100
2101
2102def get_approx_nucleoid_volume(cell_volume, nucleoid_areas, cell_area):
2103
2104    nucleoid_volume_list = []
2105
2106    for nucleoid_area in nucleoid_areas:
2107        # The power 3/2 was used to convert the estimated nucleoid area fraction into a volume fraction.
2108        nucleoid_volume = cell_volume * (nucleoid_area / cell_area) ** (3 / 2)
2109        # https://doi.org/10.1016/j.cell.2021.05.037
2110        nucleoid_volume_list.append(nucleoid_volume)
2111        total_volume = np.sum(nucleoid_volume_list)
2112
2113    return nucleoid_volume_list, total_volume
2114
2115
2116def get_nucleoid_width_variability(widths):
2117    # Because the CV is unitless and usually expressed as a percentage, it is used instead of the SD to compare the spread of data
2118    # sets that have different units of measurements or have the same units of measurements but differs greatly in magnitude.
2119    # Ratio between the standard deviation and the mean ( which in some case could be negative)
2120
2121    width_var_list = []
2122    if widths:
2123
2124        for width in widths:
2125            width_var = np.std(width) / np.mean(width)
2126            width_var_list.append(width_var)
2127
2128    avg_width_var = [np.nan] if not width_var_list else np.mean(width_var_list)
2129
2130    return width_var_list, avg_width_var
2131
2132
2133def get_obj_mesh_coords(nucleoid_meshes):
2134    x1_list, y1_list, x2_list, y2_list = [], [], [], []
2135
2136    for nucleoid_mesh in nucleoid_meshes:
2137        if np.any(nucleoid_mesh):
2138            x1 = nucleoid_mesh[:, 0]
2139            y1 = nucleoid_mesh[:, 1]
2140            x2 = nucleoid_mesh[:, 2]
2141            y2 = nucleoid_mesh[:, 3]
2142
2143            x1_list.append(x1)
2144            y1_list.append(y1)
2145            x2_list.append(x2)
2146            y2_list.append(y2)
2147        else:
2148            x1_list.append([])
2149            y1_list.append([])
2150            x2_list.append([])
2151            y2_list.append([])
2152
2153    return x1_list, y1_list, x2_list, y2_list
2154
2155
2156def measure_along_objects_midline_interp2d(
2157    midlines, im_interp2d, width=7, subpixel=0.5
2158):
2159
2160    prf_list = []
2161
2162    for midline in midlines:
2163        if np.any(midline):
2164            # Calculate a unit vector perpendicular to the midline
2165            unit_dxy = unit_perpendicular_vector(midline, closed=False)
2166
2167            # Calculate a normalized vector for the width and subpixel resolution
2168            width_normalized_dxy = unit_dxy * subpixel
2169            # It prepares the vectors for use in generating profiles at varying distances from the midline.
2170
2171            # Interpolate image values along the midline
2172            data = im_interp2d.ev(midline.T[0], midline.T[1])
2173
2174            # Iterate over width steps and calculate profile values
2175            for i in range(1, 1 + int(width * 0.5 / subpixel)):
2176
2177                dxy = width_normalized_dxy * i
2178                # This vector represents a displacement in the direction perpendicular to the midline
2179
2180                v1 = midline + dxy
2181                v2 = midline - dxy
2182                # calculate two points, v1 and v2, located at distances i units away from the midline in opposite directions.
2183
2184                p1 = im_interp2d.ev(v1.T[0], v1.T[1])
2185                ##calculate a value p1 by evaluating the image data at the coordinates v1
2186
2187                p2 = im_interp2d.ev(v2.T[0], v2.T[1])
2188
2189                data = np.vstack([p1, data, p2])
2190                # Stack arrays in sequence vertically (row wise).
2191
2192            # Calculate the average profile
2193            prf = np.average(data, axis=0)
2194            sigma = 2  # standard deviation of Gaussian filter
2195
2196            prf = gaussian_filter1d(prf, sigma)
2197            # A Gaussian Filter is a low pass filter used for reducing noise (high frequency components)
2198            # and blurring regions of an image.
2199            prf_list.append(prf)
2200        else:
2201            prf_list.append([])
2202    return prf_list
2203
2204
2205# calculates the constriction degree in relative and absolute value,
2206# and the relative position along the length of the nucleoid
2207# relative constr degree needs to divided by the height (hgt) of the local minima
2208def constr_degree(intensities, new_step_lengths, width_not_ordered):
2209    constrDegree_list = []
2210    relPos_list = []
2211    constrDegree_abs_list = []
2212    ctpos_list = []
2213
2214    for intensity, width_no, new_step_length in zip(
2215        intensities, width_not_ordered, new_step_lengths
2216    ):
2217        if not np.any(intensity):
2218            fill_nan_values(
2219                constrDegree_list, constrDegree_abs_list, ctpos_list, relPos_list
2220            )
2221        else:
2222            weighted_intensity = intensity * width_no
2223            minima = get_minima(weighted_intensity)
2224            if all(not x for x in minima) or np.sum(weighted_intensity.T) == 0:
2225                minsize, minsizeabs, ctpos, ctposr = 0, 0, np.nan, np.nan
2226            else:
2227                index, dh, dhi, hgt = identify_minima(weighted_intensity, minima)
2228                fix = np.argmax(dh)
2229                minsizeabs = dhi[fix]
2230                minsize = minsizeabs / hgt[fix]
2231                ctpos = index[fix]
2232                ctposr = np.cumsum(new_step_length)[ctpos] / np.sum(new_step_length)
2233
2234            assign_values(
2235                constrDegree_list,
2236                constrDegree_abs_list,
2237                ctpos_list,
2238                relPos_list,
2239                minsize,
2240                minsizeabs,
2241                ctpos,
2242                ctposr,
2243            )
2244
2245    avg_consDegree = [np.nan] if not constrDegree_list else np.mean(constrDegree_list)
2246    avg_abs_consDegree = (
2247        [np.nan] if not constrDegree_abs_list else np.mean(constrDegree_abs_list)
2248    )
2249    return (
2250        constrDegree_list,
2251        relPos_list,
2252        constrDegree_abs_list,
2253        ctpos_list,
2254        avg_consDegree,
2255        avg_abs_consDegree,
2256    )
2257
2258
2259def fill_nan_values(constrDegree_list, constrDegree_abs_list, ctpos_list, relPos_list):
2260    for _ in range(4):
2261        constrDegree_list.append(np.nan)
2262    ctpos_list.append(np.nan)
2263    relPos_list.append(np.nan)
2264
2265
2266def get_minima(weighted_intensity):
2267    return np.concatenate(
2268        (
2269            [False],
2270            (weighted_intensity.T[1:-1] < weighted_intensity.T[:-2])
2271            & (weighted_intensity.T[1:-1] < weighted_intensity.T[2:]),
2272            [False],
2273        )
2274    )
2275
2276
2277def identify_minima(intensity, minima):
2278    index = np.where(minima)[0]
2279    dh, dhi, hgt = np.zeros(index.shape), np.zeros(index.shape), np.zeros(index.shape)
2280    for i, k in enumerate(index):
2281        half1 = intensity.T[: k - 1]
2282        half2 = intensity.T[k + 1 :]
2283        try:
2284            dh1 = np.max(half1) - intensity.T[k]
2285            dh2 = np.max(half2) - intensity.T[k]
2286            dh[i] = np.min([dh1, dh2])
2287            dhi[i] = np.mean([dh1, dh2])
2288            hgt[i] = intensity.T[k] + dhi[i]
2289        except ValueError:
2290            pass
2291    return index, dh, dhi, hgt
2292
2293
2294def assign_values(
2295    constrDegree_list,
2296    constrDegree_abs_list,
2297    ctpos_list,
2298    relPos_list,
2299    minsize,
2300    minsizeabs,
2301    ctpos,
2302    ctposr,
2303):
2304    constrDegree_list.append(minsize)
2305    constrDegree_abs_list.append(minsizeabs)
2306    ctpos_list.append(ctpos)
2307    relPos_list.append(ctposr)
2308
2309
2310# calculate the average width of segments defined by coordinates (x1, y1) and (x2, y2)
2311def get_obj_avg_width(x1s, y1s, x2s, y2s, px):
2312    width_not_ordered_list = []
2313    width_list = []
2314
2315    for x1, y1, x2, y2 in zip(x1s, y1s, x2s, y2s):
2316        if np.any(x1):
2317            width_not_ordered = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2) * px
2318            width_not_ordered_list.append(width_not_ordered)
2319
2320            sorted_width = sorted(width_not_ordered, reverse=True)
2321            width = sum(sorted_width[: math.floor(len(sorted_width) / 3)]) / math.floor(
2322                len(sorted_width) / 3
2323            )
2324            width_list.append(width)
2325        else:
2326            width_not_ordered_list.append(np.nan)
2327            width_list.append(np.nan)  # Assign np.nan for empty lists
2328
2329    average_width = (
2330        np.nan if not width_list else np.nanmean(width_list)
2331    )  # Calculate average handling NaNs
2332    return width_list, width_not_ordered_list, average_width
2333
2334
2335def get_nucleoid_volume(x1s, y1s, x2s, y2s, step_lengths, px):
2336
2337    nucleoid_volume_list = []
2338
2339    for x1, y1, x2, y2, step_length in zip(x1s, y1s, x2s, y2s, step_lengths):
2340        if np.any(x1):
2341            d = np.sqrt((x1 - x2) ** 2 + (y1 - y2) ** 2)
2342            volume = np.trapz((np.pi * (d / 2) ** 2), dx=step_length)
2343            nucleoid_volume_list.append(volume * px * px)
2344
2345    total_volume = np.nan if not nucleoid_volume_list else np.sum(nucleoid_volume_list)
2346    return nucleoid_volume_list, total_volume
2347
2348
2349def get_object_step_length(x1s, y1s, x2s, y2s, px):
2350
2351    nuc_step_length_list = []
2352    nuc_length_list = []
2353    for x1, y1, x2, y2 in zip(x1s, y1s, x2s, y2s):
2354        if np.any(x1):
2355
2356            # calculated as the difference between corresponding x-coordinates of two line segments
2357            dx = x1[1:] + x2[1:] - x1[:-1] - x2[:-1]
2358            # x1[1:] => start from index 1
2359
2360            dy = y1[1:] + y2[1:] - y1[:-1] - y2[:-1]
2361
2362            # Calculate the step length for each segment and multiply by pixel size
2363            step_length = (np.sqrt(dx**2 + dy**2) / 2) * px
2364            nuc_step_length_list.append(step_length)
2365            nuc_length_list.append(np.sum(step_length))
2366        else:
2367            nuc_step_length_list.append([])
2368            nuc_length_list.append([])
2369    return nuc_step_length_list, nuc_length_list
2370
2371
2372def get_object_surface_area(width_nos, step_lengths):
2373
2374    sum_surface_area_list = []
2375
2376    for width_no, step_length in zip(width_nos, step_lengths):
2377        if not np.any(np.isnan(width_no)):
2378
2379            widths = width_no[1:]
2380
2381            # Calculate the surface areas for each segment
2382            surface_areas = 2 * np.pi * (widths / 2) * step_length
2383
2384            # Calculate the total surface area by summing up individual surface areas
2385            sum_surface_area = np.sum(surface_areas)
2386            sum_surface_area_list.append(sum_surface_area)
2387        else:
2388            sum_surface_area_list.append(np.nan)
2389        total_surface_area = (
2390            [np.nan] if not sum_surface_area_list else np.mean(sum_surface_area_list)
2391        )
2392
2393    return sum_surface_area_list, total_surface_area
2394
2395
2396def get_object_surface_area_over_volume(surface_areas, volumes):
2397
2398    ratio_list = []
2399
2400    for surface_area, volume in zip(surface_areas, volumes):
2401
2402        ratio = surface_area / volume
2403        ratio_list.append(ratio)
2404
2405    avg_ratio = [np.nan] if not ratio_list else np.mean(ratio_list)
2406
2407    return ratio_list, avg_ratio
2408
2409
2410def object_bending_energy(data, px):
2411    """
2412    Simplification of the formula from https://doi.org/10.1016/j.comgeo.2013.09.001
2413    """
2414    bending_energy_list = []
2415    curvatures, _, _, _, _, _, _ = get_object_curvature_characteristics(data, px)
2416    for line, curvature in zip(data, curvatures):
2417        BE = bending_energy(line, curvature, px)
2418        bending_energy_list.append(np.round(BE, 5))
2419
2420    avg_bending_energy = (
2421        [np.nan] if not bending_energy_list else np.mean(bending_energy_list)
2422    )
2423    return bending_energy_list, avg_bending_energy
2424
2425
2426def bending_energy(line, curvature, px):
2427    length = line_length(line) * px
2428    sum_squared_curvature = np.sum(curvature**2)
2429    bending_energy = sum_squared_curvature / length
2430    return np.round(bending_energy, 5)
2431
2432
2433def midline_midpoint(midline):
2434    middle_index = len(midline) // 2
2435    middle_point = midline[middle_index]
2436    return middle_point
2437
2438
2439def get_object_midpoint(midlines):
2440    centroid_coordinates = []
2441    for midline in midlines:
2442        middle_point = midline_midpoint(midline)
2443        centroid_coordinates.append(middle_point)
2444    return centroid_coordinates
2445
2446
2447def get_centroid(cropped_masks):
2448
2449    labels = label(cropped_masks)
2450    props = regionprops_table(labels, properties=("label", "centroid"))
2451    centroids = [props["centroid-0"], props["centroid-1"]]
2452
2453    return [centroids[0][0], centroids[1][0]]
2454
2455
2456def get_object_centroid(cropped_masks):
2457
2458    centroid_list = []
2459
2460    for nucleoid_mask in cropped_masks:
2461        centroid = get_centroid(nucleoid_mask)
2462        centroid_list.append(centroid)
2463
2464    return centroid_list
2465
2466
2467def get_interobject_distance(centroid_coordinates, px):
2468    num_pairs = len(list(combinations(centroid_coordinates, 2)))
2469
2470    if num_pairs == 0:
2471        return np.nan, np.nan  # No pairs found
2472
2473    distances = [distance(c1, c2) for c1, c2 in combinations(centroid_coordinates, 2)]
2474    total_distance = sum(distances) * px
2475    average_distance = total_distance / num_pairs
2476
2477    return average_distance, distances
2478
2479
2480def get_object_sinuosity(midlines):
2481
2482    sinuosity_list = []
2483
2484    for midline in midlines:
2485        temp_sinuosity = sinuosity(midline)
2486        sinuosity_list.append(temp_sinuosity)
2487    avg_sinuosity = [np.nan] if not sinuosity_list else np.mean(sinuosity_list)
2488
2489    return sinuosity_list, avg_sinuosity
2490
2491
2492def get_mesh_area(width_no, step_length):
2493    result = [step * width for step, width in zip(step_length, width_no)]
2494    mesh_area = sum(result)
2495    return mesh_area
2496
2497
2498def ratio_mesh_over_total_area(step_length, width_no, areas):
2499    ratio_list = []
2500    for step_length, width_no, area in zip(step_length, width_no, areas):
2501        mesh_area = get_mesh_area(width_no, step_length)
2502        ratio = mesh_area / area
2503        ratio_list.append(ratio)
2504    return ratio_list
2505
2506
2507def get_shortest_pole_object_distances(pole_coordinates, centroid_coordinates, px):
2508    if len(centroid_coordinates) == 0:
2509        return np.nan, np.nan  # No centroid coordinates found
2510
2511    distances = [
2512        distance(pole, c) for pole in pole_coordinates for c in centroid_coordinates
2513    ]
2514    average_distance = np.mean(distances) * px if distances else np.nan
2515
2516    return average_distance, distances
2517
2518
2519def get_pole_coordinates(mesh):
2520    pole1 = [mesh[0, 0], mesh[0, 1]]
2521    pole2 = [mesh[-1, 0], mesh[-1, 1]]
2522    return pole1, pole2
2523
2524
2525def get_pole_object_distances(mesh, centroid_coordinates, px):
2526    if len(centroid_coordinates) == 0:
2527        return np.nan, np.nan  # No centroid coordinates found
2528    pole_coordinates = get_pole_coordinates(mesh)
2529    distances = [
2530        distance(pole, c) for pole in pole_coordinates for c in centroid_coordinates
2531    ]
2532    avg_distance = np.mean(distances) * px if distances else np.nan
2533    # Sort the distances
2534    sorted_distances = np.sort(distances)
2535    # Take the two smallest distances
2536    min_2_distances = sorted_distances[:2] * px
2537    return avg_distance, min_2_distances, distances
2538
2539
2540def angle_between_vector(v1, v2):
2541    d1 = np.sqrt(np.sum(v1**2))
2542    d2 = np.sqrt(np.sum(v2**2))
2543    return np.arccos(np.dot(v1, v2) / (d1 * d2))
2544
2545
2546def projection_on_midline(centroids, midline, width, px):
2547
2548    L = line_length(midline)
2549    sorted_width = sorted(width, reverse=True)
2550    avg_width = sum(sorted_width[: math.floor(len(sorted_width) / 3)]) / math.floor(
2551        len(sorted_width) / 3
2552    )
2553    l_list = []
2554    dl_list = []
2555    norm_l_list = []
2556    norm_dl_list = []
2557    for pp in centroids:
2558        v_p = np.array(pp)[np.newaxis, :] - midline
2559        v_p_length = np.sqrt(np.sum(v_p**2, axis=1))
2560
2561        nearest_loc = np.argmin(v_p_length)
2562        if nearest_loc == len(midline) - 1:
2563            v_m = midline[-1] - midline[-2]
2564        else:
2565            v_m = midline[nearest_loc + 1] - midline[nearest_loc]
2566
2567        dl = np.dot(v_p[nearest_loc], np.array([-v_m[1], v_m[0]])) / np.linalg.norm(v_m)
2568
2569        # Determine the sign of dl based on the orientation of the midline
2570        if np.cross(v_m, v_p[nearest_loc]) < 0:
2571            dl = -dl
2572
2573        # Normalize signed distance
2574        norm_dl = dl / avg_width
2575
2576        v_x_proj = np.array([1, 0])
2577        ang1 = angle_between_vector(v_m, v_x_proj)
2578        ang2 = angle_between_vector(v_p[nearest_loc], v_x_proj)
2579        ang12 = ang1 - ang2
2580        dx = np.cos(ang12) * dl
2581        dy = np.sin(ang12) * dl
2582
2583        if nearest_loc == 0:
2584            l = 0
2585        else:
2586            l = line_length(midline[: nearest_loc + 1])
2587
2588        w = width[nearest_loc]
2589
2590        if w == 0:
2591            norm_dy = dy / (2 * np.max(width))
2592            norm_dx = dx / L
2593        else:
2594            norm_dx, norm_dy = dx / L, dy / w
2595        norm_l = l / L
2596
2597        l_list.append(np.round(l, 5) * px)
2598        dl_list.append(np.round(dl, 5) * px)
2599        norm_l_list.append(np.round(norm_l, 5))
2600        norm_dl_list.append(np.round(norm_dl, 5))
2601
2602    return l_list, dl_list, norm_l_list, norm_dl_list
2603
2604
2605def get_avg_distance_from_center(l_norm_list):
2606    average_pos = np.mean(l_norm_list)
2607    avg_dist = average_pos - 0.5
2608    return avg_dist
2609
2610
2611def get_object_intensities(object_masks, cropped_signal):
2612    individual_intensity_values = []
2613
2614    # Loop through each mask to get the intensity values
2615    for mask in object_masks:
2616        intensity_values = cropped_signal[mask != 0]
2617        individual_intensity_values.append(intensity_values)
2618
2619    # Flatten the list of individual intensities to get the combined intensities
2620    combined_intensity_values = np.concatenate(individual_intensity_values)
2621
2622    return combined_intensity_values, individual_intensity_values
2623
2624
2625def get_objects_mean(intensity_values_list):
2626    means = [np.mean(intensity_values) for intensity_values in intensity_values_list]
2627    return means
2628
2629
2630def get_objects_std(intensity_values_list):
2631    stds = [np.std(intensity_values) for intensity_values in intensity_values_list]
2632    return stds
2633
2634
2635def get_objects_kurtosis(intensity_values_list):
2636    kurtoses = [
2637        kurtosis(intensity_values) for intensity_values in intensity_values_list
2638    ]
2639    return kurtoses
2640
2641
2642def get_objects_skewness(intensity_values_list):
2643    skewnesses = [skew(intensity_values) for intensity_values in intensity_values_list]
2644    return skewnesses
2645
2646
2647def get_variance(data):
2648    return np.var(data)
2649
2650
2651# %% Profiling
2652def measure_smoothened_intensity(midline, im_interp2d, width=7, subpixel=0.5):
2653
2654    data = measure_intensity_interp2d(
2655        midline, im_interp2d, width=width, subpixel=subpixel
2656    )
2657    prf = gaussian_smoothing(data)
2658    return prf
2659
2660
2661def gaussian_smoothing(data):
2662    prf = np.average(data, axis=0)
2663    sigma = 2  # standard deviation of Gaussian filter
2664    prf = gaussian_filter1d(prf, sigma)
2665    return prf.flatten()
2666
2667
2668def measure_intensity_interp2d(midline, im_interp2d, width=7, subpixel=0.5):
2669
2670    unit_dxy = unit_perpendicular_vector(midline, closed=False)
2671    width_normalized_dxy = unit_dxy * subpixel
2672
2673    data = im_interp2d.ev(midline.T[0], midline.T[1])
2674    for i in range(1, 1 + int(width * 0.5 / subpixel)):
2675        dxy = width_normalized_dxy * i
2676        v1 = midline + dxy
2677        v2 = midline - dxy
2678        p1 = im_interp2d.ev(v1.T[0], v1.T[1])
2679        p2 = im_interp2d.ev(v2.T[0], v2.T[1])
2680        data = np.vstack([p1, data, p2])
2681    return data
2682
2683
2684def normalize_per_cell(profile):
2685    """
2686    Normalize the values in a 1D profile using Min-Max scaling.
2687
2688    Parameters:
2689        profile (array-like): A 1D array-like object representing the profile.
2690
2691    Returns:
2692        normalized_profile (ndarray): Normalized profile.
2693    """
2694    # Convert the input to a numpy array for ease of manipulation
2695    profile = np.array(profile)
2696
2697    # Calculate the minimum and maximum values in the profile
2698    min_val = np.min(profile)
2699    max_val = np.max(profile)
2700
2701    # Check if min_val equals max_val to avoid division by zero
2702    if min_val == max_val:
2703        return np.zeros_like(
2704            profile
2705        )  # Return an array of zeros if all values are the same
2706
2707    # Normalize each value to the range [0, 1]
2708    normalized_profile = (profile - min_val) / (max_val - min_val)
2709
2710    return normalized_profile
2711
2712
2713def get_cumulative_step_length_midline(midline, px):
2714    """
2715    Calculate cumulative step lengths for each segment in the midline.
2716
2717    Parameters:
2718        midline (ndarray): An array containing X and Y coordinates of the midline.
2719        px (float): The pixel size.
2720
2721    Returns:
2722        cumulative_step_lengths (ndarray): Array containing cumulative step lengths for each segment in the midline.
2723    """
2724    # Extract X and Y coordinates from the midline
2725    x = midline[:, 0]
2726    y = midline[:, 1]
2727
2728    # Compute the step lengths
2729    dx = x[1:] - x[:-1]
2730    dy = y[1:] - y[:-1]
2731    step_lengths = np.sqrt(dx**2 + dy**2) * px
2732
2733    # Calculate cumulative step lengths
2734    cumulative_step_lengths = np.zeros_like(x)
2735    cumulative_step_lengths[1:] = np.cumsum(step_lengths)
2736
2737    return cumulative_step_lengths
2738
2739
2740def linear_interpolation(array, num_values):
2741    # Original x-coordinates
2742    x_original = np.arange(len(array))
2743    # New x-coordinates for interpolation
2744    x_new = np.linspace(0, len(array) - 1, num_values)
2745    # Perform linear interpolation
2746    interpolated_values = np.interp(x_new, x_original, array)
2747    return interpolated_values
2748
2749
2750# %% Contour shift functions
2751def get_phase_mask(phase_cropped_img, phase_log_sigma=0.5):
2752
2753    inverted_phase_cropped_image = cv.bitwise_not(phase_cropped_img)
2754
2755    log_filtered_img_phase = gaussian_laplace(
2756        inverted_phase_cropped_image, sigma=phase_log_sigma
2757    )
2758    threshold_value = threshold_otsu(log_filtered_img_phase)
2759    cropped_phase_mask = log_filtered_img_phase > threshold_value
2760    cropped_phase_mask = cropped_phase_mask.astype(np.uint8)
2761
2762    return cropped_phase_mask
2763
2764
2765def closing_mask(cropped_phase_mask, closing_level=2):
2766
2767    mask_closed = closing(
2768        cropped_phase_mask, footprint=np.ones((closing_level, closing_level))
2769    )
2770
2771    return mask_closed
2772
2773
2774def find_centroid_in_largest_mask(phase_mask_closed, phase_cropped_contour):
2775    # Find the largest connected component in the phase mask
2776    labeled_mask, num_labels = label(phase_mask_closed)
2777
2778    if num_labels > 0:
2779        label_sizes = ndimage.sum(
2780            phase_mask_closed, labeled_mask, range(1, num_labels + 1)
2781        )
2782        largest_label = np.argmax(label_sizes) + 1  # +1 to adjust for 0-based indexing
2783        largest_mask = labeled_mask == largest_label
2784
2785        # Get the centroid of the largest mask
2786        phase_centroid = get_centroid(largest_mask)
2787
2788        # Check if the centroid lies within the cell contour
2789        phase_cropped_contour = phase_cropped_contour.astype(np.float32)
2790        if cv.pointPolygonTest(phase_cropped_contour, phase_centroid, False) >= 0:
2791            return [phase_centroid]
2792
2793    return []
2794
2795
2796def align(signal_centroid, phase_centroid, cropped_contour):
2797
2798    # Calculate the translation vector
2799    translation_vector = np.array(signal_centroid) - np.array(phase_centroid)
2800
2801    # Ensure translation_vector is a 1D array
2802    translation_vector = translation_vector.flatten()
2803    aligned_contour = cropped_contour.copy()
2804
2805    # colume of contour or midline is 2
2806    if aligned_contour.shape == (len(aligned_contour), 2):
2807        aligned_contour = aligned_contour + translation_vector
2808
2809    # colume of mesh is 4
2810    if aligned_contour.shape == (len(aligned_contour), 4):
2811        # Add translation vector to the first and second columns
2812        aligned_contour[:, :2] += translation_vector
2813
2814        # Add translation vector to the third and fourth columns
2815        aligned_contour[:, 2:] += translation_vector
2816
2817    return aligned_contour
2818
2819
2820def align_objects(
2821    signal_centroid, phase_centroid, cropped_mesh, cropped_contour, cropped_midline
2822):
2823    # Aligned contour
2824    aligned_contour = align(signal_centroid, phase_centroid, cropped_contour)
2825
2826    aligned_mesh = align(signal_centroid, phase_centroid, cropped_mesh)
2827
2828    aligned_midline = align(signal_centroid, phase_centroid, cropped_midline)
2829
2830    return aligned_contour, aligned_midline, aligned_mesh
2831
2832
2833def get_signal_mask(signal, contour, log_sigma, min_overlap_ratio, max_external_ratio):
2834
2835    cropped_img, _, cropped_contour, x_offset, y_offset = crop_image(
2836        image=signal, contour=contour, mask_to_crop=None
2837    )
2838    cropped_mask = draw_mask(cropped_contour, cropped_img)
2839    bgr = mean_background(cropped_img, cropped_mask)
2840    cropped_img_bgr = np.maximum(cropped_img - bgr, 0)
2841    # Apply Laplacian of Gaussian (LoG) filter to the cropped_img
2842    log_filtered_img = gaussian_laplace(cropped_img_bgr, sigma=log_sigma)
2843
2844    threshold_value = threshold_otsu(log_filtered_img)
2845
2846    object_mask = log_filtered_img < threshold_value
2847
2848    object_mask = keep_significant_masks(
2849        object_mask, cropped_mask, min_overlap_ratio, max_external_ratio
2850    )
2851
2852    return object_mask, cropped_img, x_offset, y_offset
2853
2854
2855def shift_contour(
2856    phase_image,
2857    signal_image,
2858    contour,
2859    mesh,
2860    midline,
2861    log_sigma=1.5,
2862    kernel_width=3,
2863    min_overlap_ratio=0.3,
2864    max_external_ratio=0.8,
2865    phase_log_sigma=0.5,
2866    phase_closing_level=2,
2867    signal_closing_level=12,
2868    max_shift_correction=10,
2869):
2870    """
2871    Aligns the contour, mesh, and midline based on the centroids of the phase and signal masks.
2872
2873    Parameters:
2874    - phase_image (array): The phase image.
2875    - signal_image (array): The signal image.
2876    - contour (array): The contour data.
2877    - mesh (array): The mesh data.
2878    - midline (array): The midline data.
2879    - log_sigma (float): Sigma for the log filter for the signal mask.
2880    - kernel_width (int): Width of the dilation kernel.
2881    - min_overlap_ratio (float): Minimum overlap ratio for the signal mask.
2882    - max_external_ratio (float): Maximum external ratio for the signal mask.
2883    - phase_log_sigma (float): Sigma for the log filter for the phase mask.
2884    - phase_closing_level (int): Closing level for the phase mask.
2885    - signal_closing_level (int): Closing level for the signal mask.
2886    - max_shift_correction (float): Maximum allowable distance between centroids for correction.
2887
2888    Returns:
2889    - aligned_contour (array): The aligned contour data.
2890    - aligned_mesh (array): The aligned mesh data.
2891    - aligned_midline (array): The aligned midline data.
2892    """
2893    # Phase image
2894    phase_cropped_img, _, phase_cropped_contour, x, y = crop_image(phase_image, contour)
2895
2896    # FM4-64 image
2897    signal_mask, cropped_signal, _, _ = get_signal_mask(
2898        signal_image, contour, log_sigma, min_overlap_ratio, max_external_ratio
2899    )
2900
2901    cropped_contour, cropped_midline, cropped_mesh = get_cropped_cell_data(
2902        contour, midline, mesh, x, y
2903    )
2904
2905    # Get phase mask
2906    cropped_phase_mask = get_phase_mask(
2907        phase_cropped_img, phase_log_sigma=phase_log_sigma
2908    )
2909
2910    # Fill the hole within mask
2911    phase_mask_closed = closing_mask(
2912        cropped_phase_mask, closing_level=phase_closing_level
2913    )
2914    signal_mask_closed = closing_mask(signal_mask, closing_level=signal_closing_level)
2915
2916    kernel = np.ones(
2917        (kernel_width, kernel_width), np.uint8
2918    )  # You can adjust the kernel size as needed
2919
2920    phase_mask_closed = cv.dilate(
2921        phase_mask_closed.astype(np.uint8), kernel, iterations=2
2922    )
2923    signal_mask_closed = cv.dilate(
2924        signal_mask_closed.astype(np.uint8), kernel, iterations=2
2925    )
2926
2927    # Find largest mask
2928    phase_centroid = find_centroid_in_largest_mask(
2929        phase_mask_closed, phase_cropped_contour
2930    )
2931    signal_centroid = get_centroid(signal_mask_closed)
2932
2933    # Calculate the distance between the centroids
2934    distance = np.linalg.norm(np.array(phase_centroid) - np.array(signal_centroid))
2935
2936    # Check if the distance exceeds the maximum allowable shift correction
2937    if distance > max_shift_correction:
2938        return None, None, None
2939
2940    cropped_aligned_contour, cropped_aligned_midline, cropped_aligned_mesh = (
2941        align_objects(
2942            signal_centroid,
2943            phase_centroid,
2944            cropped_mesh,
2945            cropped_contour,
2946            cropped_midline,
2947        )
2948    )
2949    aligned_contour, aligned_midline, aligned_mesh = get_uncropped_cell_data(
2950        cropped_aligned_contour, cropped_aligned_midline, cropped_aligned_mesh, x, y
2951    )
2952
2953    return aligned_contour, aligned_mesh, aligned_midline
2954
2955
2956def crop_signal_with_optional_shift(image_obj, channel, contour, shift_signal):
2957    phase_img = image_obj.image if shift_signal else None
2958    cropped_signal, cropped_mask, cropped_contour, x_offset, y_offset = crop_image(
2959        image_obj.bg_channels[channel], contour, phase_img=phase_img
2960    )
2961    return cropped_signal, cropped_mask, cropped_contour, x_offset, y_offset
2962
2963
2964# %% Membrane features
2965
2966
2967def change_contour_array_length(cell_perimeter, contour, scale=0.02, smooth=1):
2968    num = int(cell_perimeter / scale)
2969    adjust_eroded_contour = spline_approximation(
2970        contour, n=num, smooth_factor=smooth, closed=True
2971    )
2972    return adjust_eroded_contour
2973
2974def normalized_intensity_by_image(profile, cropped_image):
2975    # Convert the input to a numpy array for ease of manipulation
2976    prof = np.array(profile)
2977
2978    # Flatten the arrays to avoid issues with multidimensional input
2979    prof_flat = prof.flatten()
2980    cropped_image_flat = np.array(cropped_image).flatten()
2981
2982    # Calculate the mean of the lowest 5% values in the profile
2983    min_percentile_value = np.percentile(cropped_image_flat, 5)
2984    max_percentile_value = np.percentile(prof_flat, 95)
2985
2986    # Calculate the mean of the lowest 5% values in the cropped image
2987    min_intensity = np.mean(cropped_image_flat[cropped_image_flat <= min_percentile_value])
2988    # Calculate the mean of the highest 5% values in the profile
2989    max_intensity = np.mean(prof_flat[prof_flat >= max_percentile_value])
2990
2991    # Check if min_intensity equals max_intensity to avoid division by zero
2992    if np.isclose(max_intensity, min_intensity):
2993        return np.zeros_like(prof)  # Return an array of zeros if all values are the same
2994
2995    # Normalize each value to the range [0, 1] using the calculated min and max intensities
2996    normalized_profile = (prof - min_intensity) / (max_intensity - min_intensity)
2997
2998    return normalized_profile
2999
3000def get_homogeneity(normalized_intensity):
3001    # for normalized_int in normalized_intensity:
3002    gradient = np.gradient(normalized_intensity)
3003    homogeneity = abs(np.median(abs(gradient)) - np.mean(abs(gradient)))
3004    return homogeneity
3005
3006
3007def set_bg_zero(contour, cropped_img):
3008    cell_cropped_mask = draw_mask(contour, cropped_img)
3009    bg_zero_img = cropped_img * cell_cropped_mask
3010    return bg_zero_img
3011
3012
3013def rescale_image_255(cropped_image, max_value=255):
3014
3015    # Find the minimum and maximum intensity values
3016    min_intensity = np.min(cropped_image)
3017    max_intensity = np.max(cropped_image)
3018
3019    # Scale the intensity values to the range [0, 255]
3020    scaled_image = (
3021        (cropped_image - min_intensity) / (max_intensity - min_intensity) * max_value
3022    )
3023
3024    # Float images are not supported by graycomatrix. Convert the image to an unsigned integer type.
3025    scaled_image = scaled_image.astype(np.uint16)
3026
3027    return scaled_image
3028
3029
3030def create_co_occurrence_matrix(
3031    cropped_img,
3032    distances=[1],
3033    angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
3034    levels=256,
3035    symmetric=True,
3036    normed=True,
3037):
3038    """
3039    Create a co-occurrence matrix from a cropped image.
3040
3041    Parameters:
3042    - cropped_image: The cropped grayscale image.
3043    - distances: List of pixel pair distances.
3044    - angles: List of angles in radians for pixel pairs.
3045    - levels: Number of gray levels (typically 256 for 8-bit images).
3046    - symmetric: If True, the output matrix P[:, :, d, theta] is symmetric.
3047                 This is accomplished by ignoring the order of value pairs, so both (i, j) and (j, i) are accumulated when (i, j) is encountered for a given offset.
3048    - normed: If True, normalize each matrix P[:, :, d, theta] by dividing by the total number of accumulated co-occurrences for the given offset.
3049
3050    Returns:
3051    - co_occurrence_matrix: The co-occurrence matrix.
3052    """
3053    # Compute the co-occurrence matrix
3054    co_occurrence_matrix = graycomatrix(
3055        cropped_img,
3056        distances=distances,
3057        angles=angles,
3058        levels=levels,
3059        symmetric=True,
3060        normed=True,
3061    )
3062    co_occurrence_matrix[:, :, :, :] *= np.where(
3063        (np.arange(levels) == 0)[:, None, None, None]
3064        | (np.arange(levels) == 0)[None, :, None, None],
3065        0,
3066        1,
3067    )
3068
3069    return co_occurrence_matrix
3070
3071
3072def get_glcm_feature_means(glcm):
3073    """
3074    Compute the mean Haralick properties for a GLCM in one pass.
3075
3076    The formulas mirror skimage.feature.graycoprops so callers keep the same
3077    numerical behavior while avoiding repeated scans of the same matrix.
3078    """
3079    if glcm.ndim != 4:
3080        raise ValueError("GLCM must be a 4D array.")
3081
3082    num_level, num_level2, num_dist, num_angle = glcm.shape
3083    if num_level != num_level2:
3084        raise ValueError("num_level and num_level2 must be equal.")
3085    if num_dist <= 0:
3086        raise ValueError("num_dist must be positive.")
3087    if num_angle <= 0:
3088        raise ValueError("num_angle must be positive.")
3089
3090    normalized_glcm = glcm.astype(np.float64)
3091    glcm_sums = np.sum(normalized_glcm, axis=(0, 1), keepdims=True)
3092    glcm_sums[glcm_sums == 0] = 1
3093    normalized_glcm /= glcm_sums
3094
3095    I = np.arange(num_level, dtype=np.float64).reshape((num_level, 1, 1, 1))
3096    J = np.arange(num_level, dtype=np.float64).reshape((1, num_level, 1, 1))
3097    diff = I - J
3098    squared_diff = diff**2
3099
3100    dissimilarity = np.sum(normalized_glcm * np.abs(diff), axis=(0, 1)).mean()
3101    contrast = np.sum(normalized_glcm * squared_diff, axis=(0, 1)).mean()
3102    energy = np.sqrt(np.sum(normalized_glcm**2, axis=(0, 1))).mean()
3103    homogeneity = graycoprops(glcm, "homogeneity").mean()
3104
3105    diff_i = I - np.sum(I * normalized_glcm, axis=(0, 1))
3106    diff_j = J - np.sum(J * normalized_glcm, axis=(0, 1))
3107    std_i = np.sqrt(np.sum(normalized_glcm * (diff_i**2), axis=(0, 1)))
3108    std_j = np.sqrt(np.sum(normalized_glcm * (diff_j**2), axis=(0, 1)))
3109    cov = np.sum(normalized_glcm * (diff_i * diff_j), axis=(0, 1))
3110
3111    correlation = np.ones((num_dist, num_angle), dtype=np.float64)
3112    non_zero_mask = (std_i >= 1e-15) & (std_j >= 1e-15)
3113    correlation[non_zero_mask] = (
3114        cov[non_zero_mask] / (std_i[non_zero_mask] * std_j[non_zero_mask])
3115    )
3116    entropy = shannon_entropy(glcm, base=2)
3117
3118    return (
3119        dissimilarity,
3120        correlation.mean(),
3121        homogeneity,
3122        energy,
3123        contrast,
3124        entropy,
3125    )
3126
3127
3128def calculate_haralick_features(
3129    contour,
3130    cropped_img,
3131    distances=[2],
3132    angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
3133    levels=256,
3134):
3135    bg_zero_img = set_bg_zero(contour, cropped_img)
3136    bg_zero_img_rescaled = rescale_image_255(bg_zero_img, max_value=255)
3137    glcm = create_co_occurrence_matrix(
3138        bg_zero_img_rescaled,
3139        distances=distances,
3140        angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
3141        levels=256,
3142        symmetric=True,
3143        normed=True,
3144    )
3145    return get_glcm_feature_means(glcm)
3146
3147def align_contour_start(eroded_contour,first_point):
3148    """
3149    Rotates the eroded_contour so that its point closest to the first point
3150    of p2p_contour comes first.
3151
3152    Parameters
3153    ----------
3154    eroded_contour : np.ndarray
3155        Array of shape (N, 2), representing the contour after erosion.
3156    p2p_contour : np.ndarray
3157        Original contour array of shape (N, 2) before erosion.
3158
3159    Returns
3160    -------
3161    np.ndarray
3162        Rotated eroded_contour starting from the closest point to the original start.
3163    """
3164
3165    distances = np.linalg.norm(eroded_contour - first_point, axis=1)
3166    closest_idx = np.argmin(distances)
3167
3168    # Rotate so closest point is first
3169    aligned_contour = np.roll(eroded_contour, -closest_idx, axis=0)
3170    return aligned_contour
3171
3172def calculate_object_haralick_features(
3173    object_masks,
3174    cropped_img,
3175    distances=[2],
3176    angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
3177    levels=256,
3178):
3179    combined_mask = np.sum(object_masks, axis=0)
3180    bg_zero_img = cropped_img * combined_mask
3181    bg_zero_img_rescaled = rescale_image_255(bg_zero_img, max_value=255)
3182    glcm = create_co_occurrence_matrix(
3183        bg_zero_img_rescaled,
3184        distances=distances,
3185        angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
3186        levels=256,
3187        symmetric=True,
3188        normed=True,
3189    )
3190    return get_glcm_feature_means(glcm)
3191
3192
3193def calculate_contour_haralick_features(
3194    contour_intensities,
3195    distances=[2],
3196    angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
3197    levels=256,
3198):
3199    rescaled_contour_intensities = rescale_image_255(contour_intensities, max_value=255)
3200    glcm = create_co_occurrence_matrix(
3201        rescaled_contour_intensities,
3202        distances=distances,
3203        angles=[0, np.pi / 4, np.pi / 2, 3 * np.pi / 4],
3204        levels=256,
3205        symmetric=True,
3206        normed=True,
3207    )
3208    return get_glcm_feature_means(glcm)
3209
3210
3211# Function to split the row's array into two halves
3212def split_array(row):
3213    midpoint = len(row) // 2
3214    first_half = row[:midpoint]
3215    second_half = row[midpoint:]
3216    return first_half, second_half
3217
3218
3219def get_complemented_contour_intensity(row):
3220
3221    first_half, second_half = split_array(row)
3222
3223    # Invert the 'second_half' array
3224    inverted_second_half = second_half[::-1]
3225
3226    # Add corresponding elements from 'first_half' and inverted 'second_half' arrays
3227    complemented_contour_intensity_profile = np.array(
3228        [(a + b) / 2 for a, b in zip(first_half, inverted_second_half)]
3229    )
3230
3231    return complemented_contour_intensity_profile
3232
3233
3234# %% Shift functions
3235def shift_image(img, shift):
3236    """
3237    Correct XY drift between phase contrast image and fluorescent image(s).
3238    :param img: Input image
3239    :param shift: Subpixel XY drift
3240    :return: Drift corrected image
3241    """
3242    offset_image = fourier_shift(np.fft.fftn(img), shift)
3243    offset_image = np.fft.ifftn(offset_image)
3244    offset_image = np.round(offset_image.real)
3245    offset_image[offset_image <= 0] = 10
3246
3247    return offset_image.astype(np.uint16)