:orphan: Source: bactoscoop/utilities.py =============================== .. raw:: html
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)