celldetective 1.0.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (66) hide show
  1. celldetective/__init__.py +2 -0
  2. celldetective/__main__.py +432 -0
  3. celldetective/datasets/segmentation_annotations/blank +0 -0
  4. celldetective/datasets/signal_annotations/blank +0 -0
  5. celldetective/events.py +149 -0
  6. celldetective/extra_properties.py +100 -0
  7. celldetective/filters.py +89 -0
  8. celldetective/gui/__init__.py +20 -0
  9. celldetective/gui/about.py +44 -0
  10. celldetective/gui/analyze_block.py +563 -0
  11. celldetective/gui/btrack_options.py +898 -0
  12. celldetective/gui/classifier_widget.py +386 -0
  13. celldetective/gui/configure_new_exp.py +532 -0
  14. celldetective/gui/control_panel.py +438 -0
  15. celldetective/gui/gui_utils.py +495 -0
  16. celldetective/gui/json_readers.py +113 -0
  17. celldetective/gui/measurement_options.py +1425 -0
  18. celldetective/gui/neighborhood_options.py +452 -0
  19. celldetective/gui/plot_signals_ui.py +1042 -0
  20. celldetective/gui/process_block.py +1055 -0
  21. celldetective/gui/retrain_segmentation_model_options.py +706 -0
  22. celldetective/gui/retrain_signal_model_options.py +643 -0
  23. celldetective/gui/seg_model_loader.py +460 -0
  24. celldetective/gui/signal_annotator.py +2388 -0
  25. celldetective/gui/signal_annotator_options.py +340 -0
  26. celldetective/gui/styles.py +217 -0
  27. celldetective/gui/survival_ui.py +903 -0
  28. celldetective/gui/tableUI.py +608 -0
  29. celldetective/gui/thresholds_gui.py +1300 -0
  30. celldetective/icons/logo-large.png +0 -0
  31. celldetective/icons/logo.png +0 -0
  32. celldetective/icons/signals_icon.png +0 -0
  33. celldetective/icons/splash-test.png +0 -0
  34. celldetective/icons/splash.png +0 -0
  35. celldetective/icons/splash0.png +0 -0
  36. celldetective/icons/survival2.png +0 -0
  37. celldetective/icons/vignette_signals2.png +0 -0
  38. celldetective/icons/vignette_signals2.svg +114 -0
  39. celldetective/io.py +2050 -0
  40. celldetective/links/zenodo.json +561 -0
  41. celldetective/measure.py +1258 -0
  42. celldetective/models/segmentation_effectors/blank +0 -0
  43. celldetective/models/segmentation_generic/blank +0 -0
  44. celldetective/models/segmentation_targets/blank +0 -0
  45. celldetective/models/signal_detection/blank +0 -0
  46. celldetective/models/tracking_configs/mcf7.json +68 -0
  47. celldetective/models/tracking_configs/ricm.json +203 -0
  48. celldetective/models/tracking_configs/ricm2.json +203 -0
  49. celldetective/neighborhood.py +717 -0
  50. celldetective/scripts/analyze_signals.py +51 -0
  51. celldetective/scripts/measure_cells.py +275 -0
  52. celldetective/scripts/segment_cells.py +212 -0
  53. celldetective/scripts/segment_cells_thresholds.py +140 -0
  54. celldetective/scripts/track_cells.py +206 -0
  55. celldetective/scripts/train_segmentation_model.py +246 -0
  56. celldetective/scripts/train_signal_model.py +49 -0
  57. celldetective/segmentation.py +712 -0
  58. celldetective/signals.py +2826 -0
  59. celldetective/tracking.py +974 -0
  60. celldetective/utils.py +1681 -0
  61. celldetective-1.0.2.dist-info/LICENSE +674 -0
  62. celldetective-1.0.2.dist-info/METADATA +192 -0
  63. celldetective-1.0.2.dist-info/RECORD +66 -0
  64. celldetective-1.0.2.dist-info/WHEEL +5 -0
  65. celldetective-1.0.2.dist-info/entry_points.txt +2 -0
  66. celldetective-1.0.2.dist-info/top_level.txt +1 -0
celldetective/io.py ADDED
@@ -0,0 +1,2050 @@
1
+ from natsort import natsorted
2
+ from glob import glob
3
+ from tifffile import imread, TiffFile
4
+ import numpy as np
5
+ import os
6
+ import pandas as pd
7
+ import napari
8
+ import gc
9
+ from tqdm import tqdm
10
+ from csbdeep.utils import normalize_mi_ma
11
+ import skimage.io as skio
12
+ from scipy.ndimage import zoom
13
+ from btrack.datasets import cell_config
14
+ from magicgui import magicgui
15
+ from csbdeep.io import save_tiff_imagej_compatible
16
+ from pathlib import Path, PurePath
17
+ from shutil import copyfile
18
+ from celldetective.utils import ConfigSectionMap, extract_experiment_channels, _extract_labels_from_config, get_zenodo_files, download_zenodo_file
19
+ import json
20
+ import threading
21
+ from skimage.measure import regionprops_table
22
+
23
+
24
+ def get_experiment_wells(experiment):
25
+
26
+ """
27
+ Retrieves the list of well directories from a given experiment directory, sorted
28
+ naturally and returned as a NumPy array of strings.
29
+
30
+ Parameters
31
+ ----------
32
+ experiment : str
33
+ The path to the experiment directory from which to retrieve well directories.
34
+
35
+ Returns
36
+ -------
37
+ np.ndarray
38
+ An array of strings, each representing the full path to a well directory within the specified
39
+ experiment. The array is empty if no well directories are found.
40
+
41
+ Notes
42
+ -----
43
+ - The function assumes well directories are prefixed with 'W' and uses this to filter directories
44
+ within the experiment folder.
45
+
46
+ - Natural sorting is applied to the list of wells to ensure that the order is intuitive (e.g., 'W2'
47
+ comes before 'W10'). This sorting method is especially useful when dealing with numerical sequences
48
+ that are part of the directory names.
49
+
50
+ """
51
+
52
+ if not experiment.endswith(os.sep):
53
+ experiment += os.sep
54
+
55
+ wells = natsorted(glob(experiment + "W*" + os.sep))
56
+ return np.array(wells,dtype=str)
57
+
58
+ def get_config(experiment):
59
+
60
+ if not experiment.endswith(os.sep):
61
+ experiment += os.sep
62
+
63
+ config = experiment + 'config.ini'
64
+ config = rf"{config}"
65
+ assert os.path.exists(config),'The experiment configuration could not be located...'
66
+ return config
67
+
68
+
69
+ def get_spatial_calibration(experiment):
70
+
71
+
72
+ config = get_config(experiment)
73
+ PxToUm = float(ConfigSectionMap(config,"MovieSettings")["pxtoum"])
74
+
75
+ return PxToUm
76
+
77
+ def get_temporal_calibration(experiment):
78
+
79
+ config = get_config(experiment)
80
+ FrameToMin = float(ConfigSectionMap(config,"MovieSettings")["frametomin"])
81
+
82
+ return FrameToMin
83
+
84
+ def get_experiment_concentrations(experiment, dtype=str):
85
+
86
+
87
+ config = get_config(experiment)
88
+ wells = get_experiment_wells(experiment)
89
+ nbr_of_wells = len(wells)
90
+
91
+ concentrations = ConfigSectionMap(config,"Labels")["concentrations"].split(",")
92
+ if nbr_of_wells != len(concentrations):
93
+ concentrations = [str(s) for s in np.linspace(0,nbr_of_wells-1,nbr_of_wells)]
94
+
95
+ return np.array([dtype(c) for c in concentrations])
96
+
97
+ def get_experiment_cell_types(experiment, dtype=str):
98
+
99
+ config = get_config(experiment)
100
+ wells = get_experiment_wells(experiment)
101
+ nbr_of_wells = len(wells)
102
+
103
+ cell_types = ConfigSectionMap(config,"Labels")["cell_types"].split(",")
104
+ if nbr_of_wells != len(cell_types):
105
+ cell_types = [str(s) for s in np.linspace(0,nbr_of_wells-1,nbr_of_wells)]
106
+
107
+ return np.array([dtype(c) for c in cell_types])
108
+
109
+ def get_experiment_antibodies(experiment, dtype=str):
110
+
111
+ config = get_config(experiment)
112
+ wells = get_experiment_wells(experiment)
113
+ nbr_of_wells = len(wells)
114
+
115
+ antibodies = ConfigSectionMap(config,"Labels")["antibodies"].split(",")
116
+ if nbr_of_wells != len(antibodies):
117
+ antibodies = [str(s) for s in np.linspace(0,nbr_of_wells-1,nbr_of_wells)]
118
+
119
+ return np.array([dtype(c) for c in antibodies])
120
+
121
+ def get_experiment_pharmaceutical_agents(experiment, dtype=str):
122
+
123
+ config = get_config(experiment)
124
+ wells = get_experiment_wells(experiment)
125
+ nbr_of_wells = len(wells)
126
+
127
+ pharmaceutical_agents = ConfigSectionMap(config,"Labels")["pharmaceutical_agents"].split(",")
128
+ if nbr_of_wells != len(pharmaceutical_agents):
129
+ pharmaceutical_agents = [str(s) for s in np.linspace(0,nbr_of_wells-1,nbr_of_wells)]
130
+
131
+ return np.array([dtype(c) for c in pharmaceutical_agents])
132
+
133
+
134
+ def _interpret_wells_and_positions(experiment, well_option, position_option):
135
+
136
+ wells = get_experiment_wells(experiment)
137
+ nbr_of_wells = len(wells)
138
+
139
+ if well_option=='*':
140
+ well_indices = np.arange(len(wells))
141
+ elif isinstance(well_option, int):
142
+ well_indices = np.array([well_option], dtype=int)
143
+ elif isinstance(well_option, list):
144
+ well_indices = well_option
145
+
146
+ if position_option=='*':
147
+ position_indices = None
148
+ elif isinstance(position_option, int):
149
+ position_indices = np.array([position_option], dtype=int)
150
+ elif isinstance(position_option, list):
151
+ position_indices = position_option
152
+
153
+ return well_indices, position_indices
154
+
155
+ def extract_well_name_and_number(well):
156
+
157
+ split_well_path = well.split(os.sep)
158
+ split_well_path = list(filter(None, split_well_path))
159
+ well_name = split_well_path[-1]
160
+ well_number = int(split_well_path[-1].replace('W',''))
161
+
162
+ return well_name, well_number
163
+
164
+ def extract_position_name(pos):
165
+
166
+ split_pos_path = pos.split(os.sep)
167
+ split_pos_path = list(filter(None, split_pos_path))
168
+ pos_name = split_pos_path[-1]
169
+
170
+ return pos_name
171
+
172
+ def get_position_table(pos, population, return_path=False):
173
+
174
+ """
175
+ Retrieves the data table for a specified population at a given position, optionally returning the table's file path.
176
+
177
+ This function locates and loads a CSV data table associated with a specific population (e.g., 'targets', 'cells')
178
+ from a specified position directory. The position directory should contain an 'output/tables' subdirectory where
179
+ the CSV file named 'trajectories_{population}.csv' is expected to be found. If the file exists, it is loaded into
180
+ a pandas DataFrame; otherwise, None is returned.
181
+
182
+ Parameters
183
+ ----------
184
+ pos : str
185
+ The path to the position directory from which to load the data table.
186
+ population : str
187
+ The name of the population for which the data table is to be retrieved. This name is used to construct the
188
+ file name of the CSV file to be loaded.
189
+ return_path : bool, optional
190
+ If True, returns a tuple containing the loaded data table (or None) and the path to the CSV file. If False,
191
+ only the loaded data table (or None) is returned (default is False).
192
+
193
+ Returns
194
+ -------
195
+ pandas.DataFrame or None, or (pandas.DataFrame or None, str)
196
+ If return_path is False, returns the loaded data table as a pandas DataFrame, or None if the table file does
197
+ not exist. If return_path is True, returns a tuple where the first element is the data table (or None) and the
198
+ second element is the path to the CSV file.
199
+
200
+ Examples
201
+ --------
202
+ >>> df_pos = get_position_table('/path/to/position', 'targets')
203
+ # This will load the 'trajectories_targets.csv' table from the specified position directory into a pandas DataFrame.
204
+
205
+ >>> df_pos, table_path = get_position_table('/path/to/position', 'targets', return_path=True)
206
+ # This will load the 'trajectories_targets.csv' table and also return the path to the CSV file.
207
+
208
+ """
209
+
210
+ if not pos.endswith(os.sep):
211
+ table = os.sep.join([pos,'output','tables',f'trajectories_{population}.csv'])
212
+ else:
213
+ table = pos + os.sep.join(['output','tables',f'trajectories_{population}.csv'])
214
+
215
+ if os.path.exists(table):
216
+ df_pos = pd.read_csv(table, low_memory=False)
217
+ else:
218
+ df_pos = None
219
+
220
+ if return_path:
221
+ return df_pos, table
222
+ else:
223
+ return df_pos
224
+
225
+ def get_position_movie_path(pos, prefix=''):
226
+
227
+ if not pos.endswith(os.sep):
228
+ pos+=os.sep
229
+ movies = glob(pos+os.sep.join(['movie',prefix+'*.tif']))
230
+ if len(movies)>0:
231
+ stack_path = movies[0]
232
+ else:
233
+ stack_path = np.nan
234
+
235
+ return stack_path
236
+
237
+ def load_experiment_tables(experiment, population='targets', well_option='*',position_option='*', return_pos_info=False):
238
+
239
+
240
+ """
241
+ Loads and aggregates data tables for specified wells and positions within an experiment,
242
+ optionally returning position information alongside the aggregated data table.
243
+
244
+ This function collects data from tables associated with specific population types across
245
+ various wells and positions within an experiment. It uses the experiment's configuration
246
+ to gather metadata such as movie prefix, concentrations, cell types, antibodies, and
247
+ pharmaceutical agents. Users can specify which wells and positions to include in the
248
+ aggregation through pattern matching, and whether to include detailed position information
249
+ in the output.
250
+
251
+ Parameters
252
+ ----------
253
+ experiment : str
254
+ The path to the experiment directory.
255
+ population : str, optional
256
+ The population type to filter the tables by (default is 'targets' among 'targets and "effectors').
257
+ well_option : str, optional
258
+ A pattern to specify which wells to include (default is '*', which includes all wells).
259
+ position_option : str, optional
260
+ A pattern to specify which positions to include (default is '*', which includes all positions).
261
+ return_pos_info : bool, optional
262
+ If True, returns a tuple where the first element is the aggregated data table and the
263
+ second element is detailed position information (default is False).
264
+
265
+ Returns
266
+ -------
267
+ pandas.DataFrame or (pandas.DataFrame, pandas.DataFrame)
268
+ If return_pos_info is False, returns a pandas DataFrame aggregating the data from the
269
+ specified tables. If return_pos_info is True, returns a tuple where the first element
270
+ is the aggregated data table and the second element is a DataFrame with detailed position
271
+ information.
272
+
273
+ Raises
274
+ ------
275
+ FileNotFoundError
276
+ If the experiment directory does not exist or specified files within the directory cannot be found.
277
+ ValueError
278
+ If the specified well or position patterns do not match any directories.
279
+
280
+ Notes
281
+ -----
282
+ - This function assumes that the naming conventions and directory structure of the experiment
283
+ follow a specific format, as outlined in the experiment's configuration file.
284
+ - The function utilizes several helper functions to extract metadata, interpret well and
285
+ position patterns, and load individual position tables. Errors in these helper functions
286
+ may propagate up and affect the behavior of this function.
287
+
288
+ Examples
289
+ --------
290
+ >>> load_experiment_tables('/path/to/experiment', population='targets', well_option='W1', position_option='1-*')
291
+ # This will load and aggregate tables for the 'targets' population within well 'W1' and positions matching '1-*'.
292
+
293
+ """
294
+
295
+
296
+ config = get_config(experiment)
297
+ wells = get_experiment_wells(experiment)
298
+
299
+ movie_prefix = ConfigSectionMap(config,"MovieSettings")["movie_prefix"]
300
+ concentrations = get_experiment_concentrations(experiment, dtype=float)
301
+ cell_types = get_experiment_cell_types(experiment)
302
+ antibodies = get_experiment_antibodies(experiment)
303
+ pharmaceutical_agents = get_experiment_pharmaceutical_agents(experiment)
304
+ well_labels = _extract_labels_from_config(config,len(wells))
305
+
306
+ well_indices, position_indices = _interpret_wells_and_positions(experiment, well_option, position_option)
307
+
308
+ df = []
309
+ df_pos_info = []
310
+ real_well_index = 0
311
+
312
+ for widx, well_path in enumerate(tqdm(wells[well_indices])):
313
+
314
+ any_table = False # assume no table
315
+
316
+ well_index = widx
317
+ well_name, well_number = extract_well_name_and_number(well_path)
318
+ well_alias = well_labels[widx]
319
+
320
+ well_concentration = concentrations[widx]
321
+ well_antibody = antibodies[widx]
322
+ well_cell_type = cell_types[widx]
323
+ well_pharmaceutical_agent = pharmaceutical_agents[widx]
324
+
325
+ positions = np.array(natsorted(glob(well_path+'*'+os.sep)),dtype=str)
326
+ if position_indices is not None:
327
+ try:
328
+ positions = positions[position_indices]
329
+ except Exception as e:
330
+ print(e)
331
+ continue
332
+
333
+ real_pos_index = 0
334
+ for pidx,pos_path in enumerate(positions):
335
+
336
+ pos_name = extract_position_name(pos_path)
337
+
338
+ stack_path = get_position_movie_path(pos_path, prefix=movie_prefix)
339
+
340
+ df_pos,table = get_position_table(pos_path, population=population, return_path=True)
341
+ if df_pos is not None:
342
+
343
+ df_pos['position'] = pos_path
344
+ df_pos['well'] = well_path
345
+ df_pos['well_index'] = well_number
346
+ df_pos['well_name'] = well_name
347
+ df_pos['pos_name'] = pos_name
348
+
349
+ df_pos['concentration'] = well_concentration
350
+ df_pos['antibody'] = well_antibody
351
+ df_pos['cell_type'] = well_cell_type
352
+ df_pos['pharmaceutical_agent'] = well_pharmaceutical_agent
353
+
354
+ df.append(df_pos)
355
+ any_table = True
356
+
357
+ df_pos_info.append({'pos_path': pos_path, 'pos_index': real_pos_index, 'pos_name': pos_name, 'table_path': table, 'stack_path': stack_path,
358
+ 'well_path': well_path, 'well_index': real_well_index, 'well_name': well_name, 'well_number': well_number, 'well_alias': well_alias})
359
+
360
+ real_pos_index+=1
361
+
362
+ if any_table:
363
+ real_well_index += 1
364
+
365
+ df_pos_info = pd.DataFrame(df_pos_info)
366
+ if len(df)>0:
367
+ df = pd.concat(df)
368
+ df = df.reset_index(drop=True)
369
+ else:
370
+ df = None
371
+
372
+ if return_pos_info:
373
+ return df, df_pos_info
374
+ else:
375
+ return df
376
+
377
+
378
+
379
+ def locate_stack(position, prefix='Aligned'):
380
+
381
+ """
382
+
383
+ Locate and load a stack of images.
384
+
385
+ Parameters
386
+ ----------
387
+ position : str
388
+ The position folder within the well where the stack is located.
389
+ prefix : str, optional
390
+ The prefix used to identify the stack. The default is 'Aligned'.
391
+
392
+ Returns
393
+ -------
394
+ stack : ndarray
395
+ The loaded stack as a NumPy array.
396
+
397
+ Raises
398
+ ------
399
+ AssertionError
400
+ If no stack with the specified prefix is found.
401
+
402
+ Notes
403
+ -----
404
+ This function locates and loads a stack of images based on the specified position and prefix.
405
+ It assumes that the stack is stored in a directory named 'movie' within the specified position.
406
+ The function loads the stack as a NumPy array and performs shape manipulation to have the channels
407
+ at the end.
408
+
409
+ Examples
410
+ --------
411
+ >>> stack = locate_stack(position, prefix='Aligned')
412
+ # Locate and load a stack of images for further processing.
413
+
414
+ """
415
+
416
+ stack_path = glob(position+os.sep.join(['movie', f'{prefix}*.tif']))
417
+ assert len(stack_path)>0,f"No movie with prefix {prefix} found..."
418
+ stack = imread(stack_path[0].replace('\\','/'))
419
+ if stack.ndim==4:
420
+ stack = np.moveaxis(stack, 1, -1)
421
+ elif stack.ndim==3:
422
+ stack = stack[:,:,:,np.newaxis]
423
+
424
+ return stack
425
+
426
+ def locate_labels(position, population='target'):
427
+
428
+ """
429
+
430
+ Locate and load labels for a specific population.
431
+
432
+ Parameters
433
+ ----------
434
+ position : str
435
+ The position folder within the well where the stack is located.
436
+ population : str, optional
437
+ The population for which to locate the labels.
438
+ Valid options are 'target' and 'effector'.
439
+ The default is 'target'.
440
+
441
+ Returns
442
+ -------
443
+ labels : ndarray
444
+ The loaded labels as a NumPy array.
445
+
446
+ Notes
447
+ -----
448
+ This function locates and loads the labels for a specific population based on the specified position.
449
+ It assumes that the labels are stored in a directory named 'labels' or 'labels_effectors'
450
+ within the specified position, depending on the population.
451
+ The function loads the labels as a NumPy array.
452
+
453
+ Examples
454
+ --------
455
+ >>> labels = locate_labels(position, population='target')
456
+ # Locate and load labels for the target population.
457
+
458
+ """
459
+
460
+
461
+ if population.lower()=="target" or population.lower()=="targets":
462
+ label_path = natsorted(glob(position+os.sep.join(["labels_targets", "*.tif"])))
463
+ elif population.lower()=="effector" or population.lower()=="effectors":
464
+ label_path = natsorted(glob(position+os.sep.join(["labels_effectors", "*.tif"])))
465
+ labels = np.array([imread(i.replace('\\','/')) for i in label_path])
466
+
467
+ return labels
468
+
469
+
470
+
471
+ def locate_stack_and_labels(position, prefix='Aligned', population="target"):
472
+
473
+ """
474
+
475
+ Locate and load the stack and corresponding segmentation labels.
476
+
477
+ Parameters
478
+ ----------
479
+ position : str
480
+ The position or directory path where the stack and labels are located.
481
+ prefix : str, optional
482
+ The prefix used to identify the stack. The default is 'Aligned'.
483
+ population : str, optional
484
+ The population for which the segmentation must be located. The default is 'target'.
485
+
486
+ Returns
487
+ -------
488
+ stack : ndarray
489
+ The loaded stack as a NumPy array.
490
+ labels : ndarray
491
+ The loaded segmentation labels as a NumPy array.
492
+
493
+ Raises
494
+ ------
495
+ AssertionError
496
+ If no stack with the specified prefix is found or if the shape of the stack and labels do not match.
497
+
498
+ Notes
499
+ -----
500
+ This function locates the stack and corresponding segmentation labels based on the specified position and population.
501
+ It assumes that the stack and labels are stored in separate directories: 'movie' for the stack and 'labels' or 'labels_effectors' for the labels.
502
+ The function loads the stack and labels as NumPy arrays and performs shape validation.
503
+
504
+ Examples
505
+ --------
506
+ >>> stack, labels = locate_stack_and_labels(position, prefix='Aligned', population="target")
507
+ # Locate and load the stack and segmentation labels for further processing.
508
+
509
+ """
510
+
511
+ position = position.replace('\\','/')
512
+ labels = locate_labels(position, population=population)
513
+ stack = locate_stack(position, prefix=prefix)
514
+ assert len(stack)==len(labels),f"The shape of the stack {stack.shape} does not match with the shape of the labels {labels.shape}"
515
+
516
+ return stack,labels
517
+
518
+ def load_tracking_data(position, prefix="Aligned", population="target"):
519
+
520
+ """
521
+
522
+ Load the tracking data, labels, and stack for a given position and population.
523
+
524
+ Parameters
525
+ ----------
526
+ position : str
527
+ The position or directory where the data is located.
528
+ prefix : str, optional
529
+ The prefix used in the filenames of the stack images (default is "Aligned").
530
+ population : str, optional
531
+ The population to load the data for. Options are "target" or "effector" (default is "target").
532
+
533
+ Returns
534
+ -------
535
+ trajectories : DataFrame
536
+ The tracking data loaded as a pandas DataFrame.
537
+ labels : ndarray
538
+ The segmentation labels loaded as a numpy ndarray.
539
+ stack : ndarray
540
+ The image stack loaded as a numpy ndarray.
541
+
542
+ Notes
543
+ -----
544
+ This function loads the tracking data, labels, and stack for a given position and population.
545
+ It reads the trajectories from the appropriate CSV file based on the specified population.
546
+ The stack and labels are located using the `locate_stack_and_labels` function.
547
+ The resulting tracking data is returned as a pandas DataFrame, and the labels and stack are returned as numpy ndarrays.
548
+
549
+ Examples
550
+ --------
551
+ >>> trajectories, labels, stack = load_tracking_data(position, population="target")
552
+ # Load the tracking data, labels, and stack for the specified position and target population.
553
+
554
+ """
555
+
556
+ position = position.replace('\\','/')
557
+ if population.lower()=="target" or population.lower()=="targets":
558
+ trajectories = pd.read_csv(position+os.sep.join(['output', 'tables', 'trajectories_targets.csv']))
559
+ elif population.lower()=="effector" or population.lower()=="effectors":
560
+ trajectories = pd.read_csv(position+os.sep.join(['output', 'tables', 'trajectories_effectors.csv']))
561
+
562
+ stack,labels = locate_stack_and_labels(position, prefix=prefix, population=population)
563
+
564
+ return trajectories,labels,stack
565
+
566
+
567
+ def auto_load_number_of_frames(stack_path):
568
+
569
+ """
570
+
571
+ Automatically estimate the number of frames in a stack.
572
+
573
+ Parameters
574
+ ----------
575
+ stack_path : str
576
+ The file path to the stack.
577
+
578
+ Returns
579
+ -------
580
+ int or None
581
+ The estimated number of frames in the stack. Returns None if the number of frames cannot be determined.
582
+
583
+ Notes
584
+ -----
585
+ This function attempts to estimate the number of frames in a stack by parsing the image description metadata.
586
+ It reads the stack file using the TiffFile from the tifffile library.
587
+ It searches for metadata fields containing information about the number of slices or frames.
588
+ If the number of slices or frames is found, it returns the estimated length of the movie.
589
+ If the number of slices or frames cannot be determined, it returns None.
590
+
591
+ Examples
592
+ --------
593
+ >>> len_movie = auto_load_number_of_frames(stack_path)
594
+ # Automatically estimate the number of frames in the stack.
595
+
596
+ """
597
+
598
+ # Try to estimate automatically # frames
599
+ stack_path = stack_path.replace('\\','/')
600
+
601
+ with TiffFile(stack_path) as tif:
602
+ try:
603
+ tif_tags = {}
604
+ for tag in tif.pages[0].tags.values():
605
+ name, value = tag.name, tag.value
606
+ tif_tags[name] = value
607
+ img_desc = tif_tags["ImageDescription"]
608
+ attr = img_desc.split("\n")
609
+ except:
610
+ pass
611
+ try:
612
+ # Try nframes
613
+ nslices = int(attr[np.argmax([s.startswith("frames") for s in attr])].split("=")[-1])
614
+ if nslices>1:
615
+ len_movie = nslices
616
+ print(f"Auto-detected movie length movie: {len_movie}")
617
+ else:
618
+ break_the_code()
619
+ except:
620
+ try:
621
+ # try nslices
622
+ frames = int(attr[np.argmax([s.startswith("slices") for s in attr])].split("=")[-1])
623
+ len_movie = frames
624
+ print(f"Auto-detected movie length movie: {len_movie}")
625
+ except:
626
+ pass
627
+
628
+ try:
629
+ del tif;
630
+ del tif_tags;
631
+ del img_desc;
632
+ except:
633
+ pass
634
+ gc.collect()
635
+
636
+ return len_movie if 'len_movie' in locals() else None
637
+
638
+ def parse_isotropic_radii(string):
639
+ sections = re.split(',| ', string)
640
+ radii = []
641
+ for k,s in enumerate(sections):
642
+ if s.isdigit():
643
+ radii.append(int(s))
644
+ if '[' in s:
645
+ ring = [int(s.replace('[','')), int(sections[k+1].replace(']',''))]
646
+ radii.append(ring)
647
+ else:
648
+ pass
649
+ return radii
650
+
651
+ def get_tracking_configs_list(return_path=False):
652
+
653
+ """
654
+
655
+ Retrieve a list of available tracking configurations.
656
+
657
+ Parameters
658
+ ----------
659
+ return_path : bool, optional
660
+ If True, also returns the path to the models. Default is False.
661
+
662
+ Returns
663
+ -------
664
+ list or tuple
665
+ If return_path is False, returns a list of available tracking configurations.
666
+ If return_path is True, returns a tuple containing the list of models and the path to the models.
667
+
668
+ Notes
669
+ -----
670
+ This function retrieves the list of available tracking configurations by searching for model directories
671
+ in the predefined model path. The model path is derived from the parent directory of the current script
672
+ location and the path to the model directory. By default, it returns only the names of the models.
673
+ If return_path is set to True, it also returns the path to the models.
674
+
675
+ Examples
676
+ --------
677
+ >>> models = get_tracking_configs_list()
678
+ # Retrieve a list of available tracking configurations.
679
+
680
+ >>> models, path = get_tracking_configs_list(return_path=True)
681
+ # Retrieve a list of available tracking configurations.
682
+
683
+ """
684
+
685
+ modelpath = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective", "models", "tracking_configs", os.sep])
686
+ available_models = glob(modelpath+'*.json')
687
+ available_models = [m.replace('\\','/').split('/')[-1] for m in available_models]
688
+ available_models = [m.replace('\\','/').split('.')[0] for m in available_models]
689
+
690
+
691
+ if not return_path:
692
+ return available_models
693
+ else:
694
+ return available_models, modelpath
695
+
696
+ def interpret_tracking_configuration(config):
697
+
698
+ if isinstance(config, str):
699
+ if os.path.exists(config):
700
+ return config
701
+ else:
702
+ modelpath = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective", "models", "tracking_configs", os.sep])
703
+ if os.path.exists(modelpath+config+'.json'):
704
+ return modelpath+config+'.json'
705
+ else:
706
+ config = cell_config()
707
+ elif config is None:
708
+ config = cell_config()
709
+
710
+ return config
711
+
712
+ def get_signal_models_list(return_path=False):
713
+
714
+ """
715
+
716
+ Retrieve a list of available signal detection models.
717
+
718
+ Parameters
719
+ ----------
720
+ return_path : bool, optional
721
+ If True, also returns the path to the models. Default is False.
722
+
723
+ Returns
724
+ -------
725
+ list or tuple
726
+ If return_path is False, returns a list of available signal detection models.
727
+ If return_path is True, returns a tuple containing the list of models and the path to the models.
728
+
729
+ Notes
730
+ -----
731
+ This function retrieves the list of available signal detection models by searching for model directories
732
+ in the predefined model path. The model path is derived from the parent directory of the current script
733
+ location and the path to the model directory. By default, it returns only the names of the models.
734
+ If return_path is set to True, it also returns the path to the models.
735
+
736
+ Examples
737
+ --------
738
+ >>> models = get_signal_models_list()
739
+ # Retrieve a list of available signal detection models.
740
+
741
+ >>> models, path = get_signal_models_list(return_path=True)
742
+ # Retrieve a list of available signal detection models and the path to the models.
743
+
744
+ """
745
+
746
+ modelpath = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective", "models", "signal_detection", os.sep])
747
+ repository_models = get_zenodo_files(cat=os.sep.join(["models","signal_detection"]))
748
+
749
+ available_models = glob(modelpath+f'*{os.sep}')
750
+ available_models = [m.replace('\\','/').split('/')[-2] for m in available_models]
751
+ for rm in repository_models:
752
+ if rm not in available_models:
753
+ available_models.append(rm)
754
+
755
+ if not return_path:
756
+ return available_models
757
+ else:
758
+ return available_models, modelpath
759
+
760
+
761
+ def locate_signal_model(name):
762
+
763
+ main_dir = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective"])
764
+ modelpath = os.sep.join([main_dir, "models", "signal_detection", os.sep])
765
+ print(f'Looking for {name} in {modelpath}')
766
+ models = glob(modelpath+f'*{os.sep}')
767
+
768
+ match=None
769
+ for m in models:
770
+ if name==m.replace('\\',os.sep).split(os.sep)[-2]:
771
+ match = m
772
+ return match
773
+ # else no match, try zenodo
774
+ files, categories = get_zenodo_files()
775
+ if name in files:
776
+ index = files.index(name)
777
+ cat = categories[index]
778
+ download_zenodo_file(name, os.sep.join([main_dir, cat]))
779
+ match = os.sep.join([main_dir, cat, name])+os.sep
780
+ return match
781
+
782
+
783
+ def relabel_segmentation(labels, data, properties, column_labels={'track': "track", 'frame': 'frame', 'y': 'y', 'x': 'x', 'label': 'class_id'}, threads=1):
784
+
785
+ """
786
+
787
+ Relabel the segmentation labels based on the provided tracking data and properties.
788
+
789
+ Parameters
790
+ ----------
791
+ labels : ndarray
792
+ The original segmentation labels.
793
+ data : ndarray
794
+ The tracking data containing information about tracks, frames, y-coordinates, and x-coordinates.
795
+ properties : ndarray
796
+ The properties associated with the tracking data.
797
+ column_labels : dict, optional
798
+ A dictionary specifying the column labels for the tracking data. The default is {'track': "track",
799
+ 'frame': 'frame', 'y': 'y', 'x': 'x', 'label': 'class_id'}.
800
+
801
+ Returns
802
+ -------
803
+ ndarray
804
+ The relabeled segmentation labels.
805
+
806
+ Notes
807
+ -----
808
+ This function relabels the segmentation labels based on the provided tracking data and properties.
809
+ It creates a DataFrame from the tracking data and properties, merges them based on the indices, and sorts them by track and frame.
810
+ Then, it iterates over unique frames in the DataFrame, retrieves the tracks and identities at each frame,
811
+ and updates the corresponding labels with the new track values.
812
+
813
+ Examples
814
+ --------
815
+ >>> relabeled = relabel_segmentation(labels, data, properties, column_labels={'track': "track", 'frame': 'frame',
816
+ ... 'y': 'y', 'x': 'x', 'label': 'class_id'})
817
+ # Relabel the segmentation labels based on the provided tracking data and properties.
818
+
819
+ """
820
+
821
+
822
+ n_threads = threads
823
+ df = pd.DataFrame(data,columns=[column_labels['track'],column_labels['frame'],column_labels['y'],column_labels['x']])
824
+ df = df.merge(pd.DataFrame(properties),left_index=True, right_index=True)
825
+ df = df.sort_values(by=[column_labels['track'],column_labels['frame']])
826
+
827
+ new_labels = np.zeros_like(labels)
828
+
829
+ def rewrite_labels(indices):
830
+
831
+ for t in tqdm(indices):
832
+ f = int(t)
833
+ tracks_at_t = df.loc[df[column_labels['frame']]==f, column_labels['track']].to_numpy()
834
+ identities = df.loc[df[column_labels['frame']]==f, column_labels['label']].to_numpy()
835
+
836
+ tracks_at_t = tracks_at_t[identities==identities]
837
+ identities = identities[identities==identities]
838
+
839
+ for k in range(len(identities)):
840
+ loc_i,loc_j = np.where(labels[f]==identities[k])
841
+ new_labels[f,loc_i,loc_j] = int(tracks_at_t[k])
842
+
843
+ # Multithreading
844
+ indices = list(df[column_labels['frame']].unique())
845
+ chunks = np.array_split(indices, n_threads)
846
+ threads = []
847
+ for i in range(n_threads):
848
+ thread_i = threading.Thread(target=rewrite_labels, args=[chunks[i]])
849
+ threads.append(thread_i)
850
+ for th in threads:
851
+ th.start()
852
+ for th in threads:
853
+ th.join()
854
+
855
+ return new_labels
856
+
857
+ # def relabel_segmentation(labels, data, properties, column_labels={'track': "track", 'frame': 'frame', 'y': 'y', 'x': 'x', 'label': 'class_id'}, threads=1):
858
+
859
+ # """
860
+
861
+ # Relabel the segmentation labels based on the provided tracking data and properties.
862
+
863
+ # Parameters
864
+ # ----------
865
+ # labels : ndarray
866
+ # The original segmentation labels.
867
+ # data : ndarray
868
+ # The tracking data containing information about tracks, frames, y-coordinates, and x-coordinates.
869
+ # properties : ndarray
870
+ # The properties associated with the tracking data.
871
+ # column_labels : dict, optional
872
+ # A dictionary specifying the column labels for the tracking data. The default is {'track': "track",
873
+ # 'frame': 'frame', 'y': 'y', 'x': 'x', 'label': 'class_id'}.
874
+
875
+ # Returns
876
+ # -------
877
+ # ndarray
878
+ # The relabeled segmentation labels.
879
+
880
+ # Notes
881
+ # -----
882
+ # This function relabels the segmentation labels based on the provided tracking data and properties.
883
+ # It creates a DataFrame from the tracking data and properties, merges them based on the indices, and sorts them by track and frame.
884
+ # Then, it iterates over unique frames in the DataFrame, retrieves the tracks and identities at each frame,
885
+ # and updates the corresponding labels with the new track values.
886
+
887
+ # Examples
888
+ # --------
889
+ # >>> relabeled = relabel_segmentation(labels, data, properties, column_labels={'track': "track", 'frame': 'frame',
890
+ # ... 'y': 'y', 'x': 'x', 'label': 'class_id'})
891
+ # # Relabel the segmentation labels based on the provided tracking data and properties.
892
+
893
+ # """
894
+
895
+ # df = pd.DataFrame(data,columns=[column_labels['track'],column_labels['frame'],column_labels['y'],column_labels['x']])
896
+ # df = df.merge(pd.DataFrame(properties),left_index=True, right_index=True)
897
+ # df = df.sort_values(by=[column_labels['track'],column_labels['frame']])
898
+
899
+ # new_labels = np.zeros_like(labels)
900
+
901
+ # for t in tqdm(df[column_labels['frame']].unique()):
902
+ # f = int(t)
903
+ # tracks_at_t = df.loc[df[column_labels['frame']]==f, column_labels['track']].to_numpy()
904
+ # identities = df.loc[df[column_labels['frame']]==f, column_labels['label']].to_numpy()
905
+
906
+ # tracks_at_t = tracks_at_t[identities==identities]
907
+ # identities = identities[identities==identities]
908
+
909
+ # for k in range(len(identities)):
910
+ # loc_i,loc_j = np.where(labels[f]==identities[k])
911
+ # new_labels[f,loc_i,loc_j] = int(tracks_at_t[k])
912
+
913
+ # return new_labels
914
+
915
+ def control_tracking_btrack(position, prefix="Aligned", population="target", relabel=True, flush_memory=True, threads=1):
916
+
917
+ """
918
+ Load the necessary data for visualization of bTrack trajectories in napari.
919
+
920
+ Parameters
921
+ ----------
922
+ position : str
923
+ The path to the position directory.
924
+ prefix : str, optional
925
+ The prefix used to identify the movie file. The default is "Aligned".
926
+ population : str, optional
927
+ The population type to load, either "target" or "effector". The default is "target".
928
+
929
+ Returns
930
+ -------
931
+ None
932
+ This function displays the data in Napari for visualization and analysis.
933
+
934
+ Examples
935
+ --------
936
+ >>> control_tracking_btrack("path/to/position", population="target")
937
+ # Executes napari for visualization of target trajectories.
938
+
939
+ """
940
+
941
+ data,properties,graph,labels,stack = load_napari_data(position, prefix=prefix, population=population)
942
+ view_on_napari_btrack(data,properties,graph,labels=labels, stack=stack, relabel=relabel, flush_memory=flush_memory, threads=threads)
943
+
944
+ def view_on_napari_btrack(data,properties,graph,stack=None,labels=None,relabel=True, flush_memory=True, position=None, threads=1):
945
+
946
+ """
947
+
948
+ Visualize btrack data, including stack, labels, points, and tracks, using the napari viewer.
949
+
950
+ Parameters
951
+ ----------
952
+ data : ndarray
953
+ The btrack data containing information about tracks.
954
+ properties : ndarray
955
+ The properties associated with the btrack data.
956
+ graph : Graph
957
+ The btrack graph containing information about track connections.
958
+ stack : ndarray, optional
959
+ The stack of images to visualize. The default is None.
960
+ labels : ndarray, optional
961
+ The segmentation labels to visualize. The default is None.
962
+ relabel : bool, optional
963
+ Specify whether to relabel the segmentation labels using the provided data. The default is True.
964
+
965
+ Notes
966
+ -----
967
+ This function visualizes btrack data using the napari viewer. It adds the stack, labels, points,
968
+ and tracks to the viewer for visualization. If `relabel` is True and labels are provided, it calls
969
+ the `relabel_segmentation` function to relabel the segmentation labels based on the provided data.
970
+
971
+ Examples
972
+ --------
973
+ >>> view_on_napari_btrack(data, properties, graph, stack=stack, labels=labels, relabel=True)
974
+ # Visualize btrack data, including stack, labels, points, and tracks, using the napari viewer.
975
+
976
+ """
977
+
978
+ if (labels is not None)*relabel:
979
+ print('Relabeling the cell masks with the track ID.')
980
+ labels = relabel_segmentation(labels, data, properties, threads=threads)
981
+
982
+ vertices = data[:, 1:]
983
+ viewer = napari.Viewer()
984
+ if stack is not None:
985
+ viewer.add_image(stack,channel_axis=-1,colormap=["gray"]*stack.shape[-1])
986
+ if labels is not None:
987
+ viewer.add_labels(labels, name='segmentation',opacity=0.4)
988
+ viewer.add_points(vertices, size=4, name='points', opacity=0.3)
989
+ viewer.add_tracks(data, properties=properties, graph=graph, name='tracks')
990
+ viewer.show(block=True)
991
+
992
+ if flush_memory:
993
+ # temporary fix for slight napari memory leak
994
+ for i in range(10000):
995
+ try:
996
+ viewer.layers.pop()
997
+ except:
998
+ pass
999
+
1000
+ del viewer
1001
+ del stack
1002
+ del labels
1003
+ gc.collect()
1004
+
1005
+ def load_napari_data(position, prefix="Aligned", population="target"):
1006
+
1007
+ """
1008
+ Load the necessary data for visualization in napari.
1009
+
1010
+ Parameters
1011
+ ----------
1012
+ position : str
1013
+ The path to the position or experiment directory.
1014
+ prefix : str, optional
1015
+ The prefix used to identify the the movie file. The default is "Aligned".
1016
+ population : str, optional
1017
+ The population type to load, either "target" or "effector". The default is "target".
1018
+
1019
+ Returns
1020
+ -------
1021
+ tuple
1022
+ A tuple containing the loaded data, properties, graph, labels, and stack.
1023
+
1024
+ Examples
1025
+ --------
1026
+ >>> data, properties, graph, labels, stack = load_napari_data("path/to/position")
1027
+ # Load the necessary data for visualization of target trajectories.
1028
+
1029
+ """
1030
+ position = position.replace('\\','/')
1031
+ if population.lower()=="target" or population.lower()=="targets":
1032
+ napari_data = np.load(position+os.sep.join(['output','tables','napari_target_trajectories.npy']), allow_pickle=True)
1033
+ elif population.lower()=="effector" or population.lower()=="effectors":
1034
+ napari_data = np.load(position+os.sep.join(['output', 'tables', 'napari_effector_trajectories.npy']), allow_pickle=True)
1035
+ data = napari_data.item()['data']
1036
+ properties = napari_data.item()['properties']
1037
+ graph = napari_data.item()['graph']
1038
+
1039
+ stack,labels = locate_stack_and_labels(position, prefix=prefix, population=population)
1040
+
1041
+ return data,properties,graph,labels,stack
1042
+
1043
+ from skimage.measure import label
1044
+
1045
+ def auto_correct_masks(masks):
1046
+
1047
+ """
1048
+ Automatically corrects segmentation masks by splitting disconnected objects sharing the same label.
1049
+
1050
+ This function examines each labeled object in the input masks and splits objects whose bounding box
1051
+ area is significantly larger than their actual area, indicating potential merging of multiple objects.
1052
+ It uses geometric properties to identify such cases and applies a relabeling to separate merged objects.
1053
+
1054
+ Parameters
1055
+ ----------
1056
+ masks : ndarray
1057
+ A 2D numpy array representing the segmentation masks, where each object is labeled with a unique integer.
1058
+
1059
+ Returns
1060
+ -------
1061
+ ndarray
1062
+ A 2D numpy array of the corrected segmentation masks with potentially merged objects separated and
1063
+ relabeled.
1064
+
1065
+ Notes
1066
+ -----
1067
+ - The function uses bounding box area and actual object area to identify potentially merged objects.
1068
+ Objects are considered potentially merged if their bounding box area is more than twice their actual area.
1069
+ - Relabeling of objects is done sequentially, adding to the maximum label number found in the original
1070
+ masks to ensure new labels do not overlap with existing ones.
1071
+ - This function relies on `skimage.measure.label` for relabeling and `skimage.measure.regionprops_table`
1072
+ for calculating object properties.
1073
+
1074
+ """
1075
+
1076
+ props = pd.DataFrame(regionprops_table(masks,properties=('label','area','area_bbox')))
1077
+ max_lbl = props['label'].max()
1078
+ corrected_lbl = masks.copy().astype(int)
1079
+
1080
+ for cell in props['label'].unique():
1081
+
1082
+ bbox_area = props.loc[props['label']==cell, 'area_bbox'].values
1083
+ area = props.loc[props['label']==cell, 'area'].values
1084
+
1085
+ if bbox_area > 1.75*area: #condition for anomaly
1086
+
1087
+ lbl = masks==cell
1088
+ lbl = lbl.astype(int)
1089
+
1090
+ relabelled = label(lbl,connectivity=2)
1091
+ relabelled += max_lbl
1092
+ relabelled[np.where(lbl==0)] = 0
1093
+
1094
+ corrected_lbl[np.where(relabelled != 0)] = relabelled[np.where(relabelled!=0)]
1095
+
1096
+ max_lbl = np.amax(corrected_lbl)
1097
+
1098
+ return corrected_lbl
1099
+
1100
+
1101
+
1102
+ def control_segmentation_napari(position, prefix='Aligned', population="target", flush_memory=False):
1103
+
1104
+ """
1105
+
1106
+ Control the visualization of segmentation labels using the napari viewer.
1107
+
1108
+ Parameters
1109
+ ----------
1110
+ position : str
1111
+ The position or directory path where the segmentation labels and stack are located.
1112
+ prefix : str, optional
1113
+ The prefix used to identify the stack. The default is 'Aligned'.
1114
+ population : str, optional
1115
+ The population type for which the segmentation is performed. The default is 'target'.
1116
+
1117
+ Notes
1118
+ -----
1119
+ This function loads the segmentation labels and stack corresponding to the specified position and population.
1120
+ It then creates a napari viewer and adds the stack and labels as layers for visualization.
1121
+
1122
+ Examples
1123
+ --------
1124
+ >>> control_segmentation_napari(position, prefix='Aligned', population="target")
1125
+ # Control the visualization of segmentation labels using the napari viewer.
1126
+
1127
+ """
1128
+
1129
+ def export_labels():
1130
+ labels_layer = viewer.layers['segmentation'].data
1131
+ for t,im in enumerate(tqdm(labels_layer)):
1132
+
1133
+ try:
1134
+ im = auto_correct_masks(im)
1135
+ except Exception as e:
1136
+ print(e)
1137
+
1138
+ save_tiff_imagej_compatible(output_folder+f"{str(t).zfill(4)}.tif", im.astype(np.int16), axes='YX')
1139
+ print("The labels have been successfully rewritten.")
1140
+
1141
+ def export_annotation():
1142
+
1143
+ # Locate experiment config
1144
+ parent1 = Path(position).parent
1145
+ expfolder = parent1.parent
1146
+ config = PurePath(expfolder,Path("config.ini"))
1147
+ expfolder = str(expfolder)
1148
+ exp_name = os.path.split(expfolder)[-1]
1149
+ print(exp_name)
1150
+
1151
+ spatial_calibration = float(ConfigSectionMap(config,"MovieSettings")["pxtoum"])
1152
+ channel_names, channel_indices = extract_experiment_channels(config)
1153
+
1154
+ annotation_folder = expfolder + os.sep + f'annotations_{population}' + os.sep
1155
+ if not os.path.exists(annotation_folder):
1156
+ os.mkdir(annotation_folder)
1157
+
1158
+ print('exporting!')
1159
+ t = viewer.dims.current_step[0]
1160
+ labels_layer = viewer.layers['segmentation'].data[t] # at current time
1161
+
1162
+ try:
1163
+ labels_layer = auto_correct_masks(labels_layer)
1164
+ except Exception as e:
1165
+ print(e)
1166
+
1167
+ fov_export = True
1168
+
1169
+ if "Shapes" in viewer.layers:
1170
+ squares = viewer.layers['Shapes'].data
1171
+ test_in_frame = np.array([squares[i][0,0]==t and len(squares[i])==4 for i in range(len(squares))])
1172
+ squares = np.array(squares)
1173
+ squares = squares[test_in_frame]
1174
+ nbr_squares = len(squares)
1175
+ print(f"Found {nbr_squares} ROIS")
1176
+ if nbr_squares>0:
1177
+ # deactivate field of view mode
1178
+ fov_export = False
1179
+
1180
+ for k,sq in enumerate(squares):
1181
+ print(f"ROI: {sq}")
1182
+ xmin = int(sq[0,1])
1183
+ xmax = int(sq[2,1])
1184
+ if xmax<xmin:
1185
+ xmax,xmin = xmin,xmax
1186
+ ymin = int(sq[0,2])
1187
+ ymax = int(sq[1,2])
1188
+ if ymax<ymin:
1189
+ ymax,ymin = ymin,ymax
1190
+ print(f"{xmin=};{xmax=};{ymin=};{ymax=}")
1191
+ frame = viewer.layers['Image'].data[t][xmin:xmax,ymin:ymax]
1192
+ if frame.shape[1] < 256 or frame.shape[0] < 256:
1193
+ print("crop too small!")
1194
+ continue
1195
+ multichannel = [frame]
1196
+ for i in range(len(channel_indices)-1):
1197
+ try:
1198
+ frame = viewer.layers[f'Image [{i+1}]'].data[t][xmin:xmax,ymin:ymax]
1199
+ multichannel.append(frame)
1200
+ except:
1201
+ pass
1202
+ multichannel = np.array(multichannel)
1203
+ save_tiff_imagej_compatible(annotation_folder + f"{exp_name}_{position.split(os.sep)[-2]}_{str(t).zfill(4)}_roi_{xmin}_{xmax}_{ymin}_{ymax}_labelled.tif", labels_layer[xmin:xmax,ymin:ymax].astype(np.int16), axes='YX')
1204
+ save_tiff_imagej_compatible(annotation_folder + f"{exp_name}_{position.split(os.sep)[-2]}_{str(t).zfill(4)}_roi_{xmin}_{xmax}_{ymin}_{ymax}.tif", multichannel, axes='CYX')
1205
+ info = {"spatial_calibration": spatial_calibration, "channels": list(channel_names)}
1206
+ info_name = annotation_folder + f"{exp_name}_{position.split(os.sep)[-2]}_{str(t).zfill(4)}_roi_{xmin}_{xmax}_{ymin}_{ymax}.json"
1207
+ with open(info_name, 'w') as f:
1208
+ json.dump(info, f, indent=4)
1209
+
1210
+ if fov_export:
1211
+ frame = viewer.layers['Image'].data[t]
1212
+ multichannel = [frame]
1213
+ for i in range(len(channel_indices)-1):
1214
+ try:
1215
+ frame = viewer.layers[f'Image [{i+1}]'].data[t]
1216
+ multichannel.append(frame)
1217
+ except:
1218
+ pass
1219
+ multichannel = np.array(multichannel)
1220
+ save_tiff_imagej_compatible(annotation_folder + f"{exp_name}_{position.split(os.sep)[-2]}_{str(t).zfill(4)}_labelled.tif", labels_layer, axes='YX')
1221
+ save_tiff_imagej_compatible(annotation_folder + f"{exp_name}_{position.split(os.sep)[-2]}_{str(t).zfill(4)}.tif", multichannel, axes='CYX')
1222
+ info = {"spatial_calibration": spatial_calibration, "channels": list(channel_names)}
1223
+ info_name = annotation_folder + f"{exp_name}_{position.split(os.sep)[-2]}_{str(t).zfill(4)}.json"
1224
+ with open(info_name, 'w') as f:
1225
+ json.dump(info, f, indent=4)
1226
+ print('Done.')
1227
+
1228
+ @magicgui(call_button='Save the modified labels')
1229
+ def save_widget():
1230
+ return export_labels()
1231
+
1232
+ @magicgui(call_button='Export the annotation\nof the current frame')
1233
+ def export_widget():
1234
+ return export_annotation()
1235
+
1236
+ stack,labels = locate_stack_and_labels(position, prefix=prefix, population=population)
1237
+
1238
+ if not population.endswith('s'):
1239
+ population+='s'
1240
+ output_folder = position+f'labels_{population}{os.sep}'
1241
+
1242
+ viewer = napari.Viewer()
1243
+ viewer.add_image(stack,channel_axis=-1,colormap=["gray"]*stack.shape[-1])
1244
+ viewer.add_labels(labels.astype(int), name='segmentation',opacity=0.4)
1245
+ viewer.window.add_dock_widget(save_widget, area='right')
1246
+ viewer.window.add_dock_widget(export_widget, area='right')
1247
+ viewer.show(block=True)
1248
+
1249
+ if flush_memory:
1250
+ # temporary fix for slight napari memory leak
1251
+ for i in range(10000):
1252
+ try:
1253
+ viewer.layers.pop()
1254
+ except:
1255
+ pass
1256
+
1257
+ del viewer
1258
+ del stack
1259
+ del labels
1260
+ gc.collect()
1261
+
1262
+
1263
+ def _view_on_napari(tracks=None, stack=None, labels=None):
1264
+
1265
+ """
1266
+
1267
+ Visualize tracks, stack, and labels using Napari.
1268
+
1269
+ Parameters
1270
+ ----------
1271
+ tracks : pandas DataFrame
1272
+ DataFrame containing track information.
1273
+ stack : numpy array, optional
1274
+ Stack of images with shape (T, Y, X, C), where T is the number of frames, Y and X are the spatial dimensions,
1275
+ and C is the number of channels. Default is None.
1276
+ labels : numpy array, optional
1277
+ Label stack with shape (T, Y, X) representing cell segmentations. Default is None.
1278
+
1279
+ Returns
1280
+ -------
1281
+ None
1282
+
1283
+ Notes
1284
+ -----
1285
+ This function visualizes tracks, stack, and labels using Napari, an interactive multi-dimensional image viewer.
1286
+ The tracks are represented as line segments on the viewer. If a stack is provided, it is displayed as an image.
1287
+ If labels are provided, they are displayed as a segmentation overlay on the stack.
1288
+
1289
+ Examples
1290
+ --------
1291
+ >>> tracks = pd.DataFrame({'track': [1, 2, 3], 'time': [1, 1, 1],
1292
+ ... 'x': [10, 20, 30], 'y': [15, 25, 35]})
1293
+ >>> stack = np.random.rand(100, 100, 3)
1294
+ >>> labels = np.random.randint(0, 2, (100, 100))
1295
+ >>> view_on_napari(tracks, stack=stack, labels=labels)
1296
+ # Visualize tracks, stack, and labels using Napari.
1297
+
1298
+ """
1299
+
1300
+ viewer = napari.Viewer()
1301
+ if stack is not None:
1302
+ viewer.add_image(stack,channel_axis=-1,colormap=["gray"]*stack.shape[-1])
1303
+ if labels is not None:
1304
+ viewer.add_labels(labels, name='segmentation',opacity=0.4)
1305
+ if tracks is not None:
1306
+ viewer.add_tracks(tracks, name='tracks')
1307
+ viewer.show(block=True)
1308
+
1309
+ def control_tracking_table(position, calibration=1, prefix="Aligned", population="target",
1310
+ column_labels={'track': "TRACK_ID", 'frame': 'FRAME', 'y': 'POSITION_Y', 'x': 'POSITION_X', 'label': 'class_id'}):
1311
+
1312
+ """
1313
+
1314
+ Control the tracking table and visualize tracks using Napari.
1315
+
1316
+ Parameters
1317
+ ----------
1318
+ position : str
1319
+ The position or directory of the tracking data.
1320
+ calibration : float, optional
1321
+ Calibration factor for converting pixel coordinates to physical units. Default is 1.
1322
+ prefix : str, optional
1323
+ Prefix used for the tracking data file. Default is "Aligned".
1324
+ population : str, optional
1325
+ Population type, either "target" or "effector". Default is "target".
1326
+ column_labels : dict, optional
1327
+ Dictionary containing the column labels for the tracking table. Default is
1328
+ {'track': "TRACK_ID", 'frame': 'FRAME', 'y': 'POSITION_Y', 'x': 'POSITION_X', 'label': 'class_id'}.
1329
+
1330
+ Returns
1331
+ -------
1332
+ None
1333
+
1334
+ Notes
1335
+ -----
1336
+ This function loads the tracking data, applies calibration to the spatial coordinates, and visualizes the tracks
1337
+ using Napari. The tracking data is loaded from the specified `position` directory with the given `prefix` and
1338
+ `population`. The spatial coordinates (x, y) in the tracking table are divided by the `calibration` factor to
1339
+ convert them from pixel units to the specified physical units. The tracks are then visualized using Napari.
1340
+
1341
+ Examples
1342
+ --------
1343
+ >>> control_tracking_table('path/to/tracking_data', calibration=0.1, prefix='Aligned', population='target')
1344
+ # Control the tracking table and visualize tracks using Napari.
1345
+
1346
+ """
1347
+
1348
+ position = position.replace('\\','/')
1349
+ tracks,labels,stack = load_tracking_data(position, prefix=prefix, population=population)
1350
+ tracks = tracks.loc[:, [column_labels['track'], column_labels['frame'], column_labels['y'], column_labels['x']]].to_numpy()
1351
+ tracks[:,-2:] /= calibration
1352
+ _view_on_napari(tracks,labels=labels, stack=stack)
1353
+
1354
+
1355
+ def get_segmentation_models_list(mode='targets', return_path=False):
1356
+
1357
+ if mode=='targets':
1358
+ modelpath = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective", "models", "segmentation_targets", os.sep])
1359
+ repository_models = get_zenodo_files(cat=os.sep.join(["models","segmentation_targets"]))
1360
+ elif mode=='effectors':
1361
+ modelpath = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective", "models", "segmentation_effectors", os.sep])
1362
+ repository_models = get_zenodo_files(cat=os.sep.join(["models","segmentation_effectors"]))
1363
+ elif mode=='generic':
1364
+ modelpath = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective", "models", "segmentation_generic", os.sep])
1365
+ repository_models = get_zenodo_files(cat=os.sep.join(["models","segmentation_generic"]))
1366
+
1367
+ available_models = natsorted(glob(modelpath+'*/'))
1368
+ available_models = [m.replace('\\','/').split('/')[-2] for m in available_models]
1369
+ for rm in repository_models:
1370
+ if rm not in available_models:
1371
+ available_models.append(rm)
1372
+
1373
+ if not return_path:
1374
+ return available_models
1375
+ else:
1376
+ return available_models, modelpath
1377
+
1378
+ def locate_segmentation_model(name):
1379
+
1380
+ """
1381
+ Locates a specified segmentation model within the local 'celldetective' directory or
1382
+ downloads it from Zenodo if not found locally.
1383
+
1384
+ This function attempts to find a segmentation model by name within a predefined directory
1385
+ structure starting from the 'celldetective/models/segmentation*' path. If the model is not
1386
+ found locally, it then tries to locate and download the model from Zenodo, placing it into
1387
+ the appropriate category directory within 'celldetective'. The function prints the search
1388
+ directory path and returns the path to the found or downloaded model.
1389
+
1390
+ Parameters
1391
+ ----------
1392
+ name : str
1393
+ The name of the segmentation model to locate.
1394
+
1395
+ Returns
1396
+ -------
1397
+ str or None
1398
+ The full path to the located or downloaded segmentation model directory, or None if the
1399
+ model could not be found or downloaded.
1400
+
1401
+ Raises
1402
+ ------
1403
+ FileNotFoundError
1404
+ If the model cannot be found locally and also cannot be found or downloaded from Zenodo.
1405
+
1406
+ """
1407
+
1408
+ main_dir = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective"])
1409
+ modelpath = os.sep.join([main_dir, "models", "segmentation*", os.sep])
1410
+ print(f'Looking for {name} in {modelpath}')
1411
+ models = glob(modelpath+f'*{os.sep}')
1412
+
1413
+ match=None
1414
+ for m in models:
1415
+ if name==m.replace('\\',os.sep).split(os.sep)[-2]:
1416
+ match = m
1417
+ return match
1418
+ # else no match, try zenodo
1419
+ files, categories = get_zenodo_files()
1420
+ if name in files:
1421
+ index = files.index(name)
1422
+ cat = categories[index]
1423
+ download_zenodo_file(name, os.sep.join([main_dir, cat]))
1424
+ match = os.sep.join([main_dir, cat, name])+os.sep
1425
+ return match
1426
+
1427
+
1428
+ def get_segmentation_datasets_list(return_path=False):
1429
+
1430
+ """
1431
+ Retrieves a list of available segmentation datasets from both the local 'celldetective/datasets/segmentation_annotations'
1432
+ directory and a Zenodo repository, optionally returning the path to the local datasets directory.
1433
+
1434
+ This function compiles a list of available segmentation datasets by first identifying datasets stored locally
1435
+ within a specified path related to the script's directory. It then extends this list with datasets available
1436
+ in a Zenodo repository, ensuring no duplicates are added. The function can return just the list of dataset
1437
+ names or, if specified, also return the path to the local datasets directory.
1438
+
1439
+ Parameters
1440
+ ----------
1441
+ return_path : bool, optional
1442
+ If True, the function returns a tuple containing the list of available dataset names and the path to the
1443
+ local datasets directory. If False, only the list of dataset names is returned (default is False).
1444
+
1445
+ Returns
1446
+ -------
1447
+ list or (list, str)
1448
+ If return_path is False, returns a list of strings, each string being the name of an available dataset.
1449
+ If return_path is True, returns a tuple where the first element is this list and the second element is a
1450
+ string representing the path to the local datasets directory.
1451
+
1452
+ """
1453
+
1454
+
1455
+ datasets_path = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective", "datasets", "segmentation_annotations", os.sep])
1456
+ repository_datasets = get_zenodo_files(cat=os.sep.join(["datasets","segmentation_annotations"]))
1457
+
1458
+ available_datasets = natsorted(glob(datasets_path+'*/'))
1459
+ available_datasets = [m.replace('\\','/').split('/')[-2] for m in available_datasets]
1460
+ for rm in repository_datasets:
1461
+ if rm not in available_datasets:
1462
+ available_datasets.append(rm)
1463
+
1464
+ if not return_path:
1465
+ return available_datasets
1466
+ else:
1467
+ return available_datasets, datasets_path
1468
+
1469
+
1470
+
1471
+ def locate_segmentation_dataset(name):
1472
+
1473
+ """
1474
+ Locates a specified segmentation dataset within the local 'celldetective/datasets/segmentation_annotations' directory
1475
+ or downloads it from Zenodo if not found locally.
1476
+
1477
+ This function attempts to find a segmentation dataset by name within a predefined directory structure. If the dataset
1478
+ is not found locally, it then tries to locate and download the dataset from Zenodo, placing it into the appropriate
1479
+ category directory within 'celldetective'. The function prints the search directory path and returns the path to the
1480
+ found or downloaded dataset.
1481
+
1482
+ Parameters
1483
+ ----------
1484
+ name : str
1485
+ The name of the segmentation dataset to locate.
1486
+
1487
+ Returns
1488
+ -------
1489
+ str or None
1490
+ The full path to the located or downloaded segmentation dataset directory, or None if the dataset could not be
1491
+ found or downloaded.
1492
+
1493
+ Raises
1494
+ ------
1495
+ FileNotFoundError
1496
+ If the dataset cannot be found locally and also cannot be found or downloaded from Zenodo.
1497
+
1498
+ """
1499
+
1500
+ main_dir = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective"])
1501
+ modelpath = os.sep.join([main_dir, "datasets", "segmentation_annotations", os.sep])
1502
+ print(f'Looking for {name} in {modelpath}')
1503
+ models = glob(modelpath+f'*{os.sep}')
1504
+
1505
+ match=None
1506
+ for m in models:
1507
+ if name==m.replace('\\',os.sep).split(os.sep)[-2]:
1508
+ match = m
1509
+ return match
1510
+ # else no match, try zenodo
1511
+ files, categories = get_zenodo_files()
1512
+ if name in files:
1513
+ index = files.index(name)
1514
+ cat = categories[index]
1515
+ download_zenodo_file(name, os.sep.join([main_dir, cat]))
1516
+ match = os.sep.join([main_dir, cat, name])+os.sep
1517
+ return match
1518
+
1519
+
1520
+ def get_signal_datasets_list(return_path=False):
1521
+
1522
+ """
1523
+ Retrieves a list of available signal datasets from both the local 'celldetective/datasets/signal_annotations' directory
1524
+ and a Zenodo repository, optionally returning the path to the local datasets directory.
1525
+
1526
+ This function compiles a list of available signal datasets by first identifying datasets stored locally within a specified
1527
+ path related to the script's directory. It then extends this list with datasets available in a Zenodo repository, ensuring
1528
+ no duplicates are added. The function can return just the list of dataset names or, if specified, also return the path to
1529
+ the local datasets directory.
1530
+
1531
+ Parameters
1532
+ ----------
1533
+ return_path : bool, optional
1534
+ If True, the function returns a tuple containing the list of available dataset names and the path to the local datasets
1535
+ directory. If False, only the list of dataset names is returned (default is False).
1536
+
1537
+ Returns
1538
+ -------
1539
+ list or (list, str)
1540
+ If return_path is False, returns a list of strings, each string being the name of an available dataset. If return_path
1541
+ is True, returns a tuple where the first element is this list and the second element is a string representing the path
1542
+ to the local datasets directory.
1543
+
1544
+ """
1545
+
1546
+ datasets_path = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective", "datasets", "signal_annotations", os.sep])
1547
+ repository_datasets = get_zenodo_files(cat=os.sep.join(["datasets","signal_annotations"]))
1548
+
1549
+ available_datasets = natsorted(glob(datasets_path+'*/'))
1550
+ available_datasets = [m.replace('\\','/').split('/')[-2] for m in available_datasets]
1551
+ for rm in repository_datasets:
1552
+ if rm not in available_datasets:
1553
+ available_datasets.append(rm)
1554
+
1555
+ if not return_path:
1556
+ return available_datasets
1557
+ else:
1558
+ return available_datasets, datasets_path
1559
+
1560
+ def locate_signal_dataset(name):
1561
+
1562
+ """
1563
+ Locates a specified signal dataset within the local 'celldetective/datasets/signal_annotations' directory or downloads
1564
+ it from Zenodo if not found locally.
1565
+
1566
+ This function attempts to find a signal dataset by name within a predefined directory structure. If the dataset is not
1567
+ found locally, it then tries to locate and download the dataset from Zenodo, placing it into the appropriate category
1568
+ directory within 'celldetective'. The function prints the search directory path and returns the path to the found or
1569
+ downloaded dataset.
1570
+
1571
+ Parameters
1572
+ ----------
1573
+ name : str
1574
+ The name of the signal dataset to locate.
1575
+
1576
+ Returns
1577
+ -------
1578
+ str or None
1579
+ The full path to the located or downloaded signal dataset directory, or None if the dataset could not be found or
1580
+ downloaded.
1581
+
1582
+ Raises
1583
+ ------
1584
+ FileNotFoundError
1585
+ If the dataset cannot be found locally and also cannot be found or downloaded from Zenodo.
1586
+
1587
+ """
1588
+
1589
+ main_dir = os.sep.join([os.path.split(os.path.dirname(os.path.realpath(__file__)))[0],"celldetective"])
1590
+ modelpath = os.sep.join([main_dir, "datasets", "signal_annotations", os.sep])
1591
+ print(f'Looking for {name} in {modelpath}')
1592
+ models = glob(modelpath+f'*{os.sep}')
1593
+
1594
+ match=None
1595
+ for m in models:
1596
+ if name==m.replace('\\',os.sep).split(os.sep)[-2]:
1597
+ match = m
1598
+ return match
1599
+ # else no match, try zenodo
1600
+ files, categories = get_zenodo_files()
1601
+ if name in files:
1602
+ index = files.index(name)
1603
+ cat = categories[index]
1604
+ download_zenodo_file(name, os.sep.join([main_dir, cat]))
1605
+ match = os.sep.join([main_dir, cat, name])+os.sep
1606
+ return match
1607
+
1608
+ def normalize(frame, percentiles=(0.0,99.99), values=None, ignore_gray_value=0., clip=False, amplification=None, dtype=float):
1609
+
1610
+ """
1611
+
1612
+ Normalize the intensity values of a frame.
1613
+
1614
+ Parameters
1615
+ ----------
1616
+ frame : ndarray
1617
+ The input frame to be normalized.
1618
+ percentiles : tuple, optional
1619
+ The percentiles used to determine the minimum and maximum values for normalization. Default is (0.0, 99.99).
1620
+ values : tuple or None, optional
1621
+ The specific minimum and maximum values to use for normalization. If None, percentiles are used. Default is None.
1622
+ ignore_gray_value : float or None, optional
1623
+ The gray value to ignore during normalization. If specified, the pixels with this value will not be normalized. Default is 0.0.
1624
+
1625
+ Returns
1626
+ -------
1627
+ ndarray
1628
+ The normalized frame.
1629
+
1630
+ Notes
1631
+ -----
1632
+ This function performs intensity normalization on a frame. It computes the minimum and maximum values for normalization either
1633
+ using the specified values or by calculating percentiles from the frame. The frame is then normalized between the minimum and
1634
+ maximum values using the `normalize_mi_ma` function. If `ignore_gray_value` is specified, the pixels with this value will be
1635
+ left unmodified during normalization.
1636
+
1637
+ Examples
1638
+ --------
1639
+ >>> frame = np.array([[10, 20, 30],
1640
+ [40, 50, 60],
1641
+ [70, 80, 90]])
1642
+ >>> normalized = normalize(frame)
1643
+ >>> normalized
1644
+
1645
+ array([[0. , 0.2, 0.4],
1646
+ [0.6, 0.8, 1. ],
1647
+ [1.2, 1.4, 1.6]], dtype=float32)
1648
+
1649
+ >>> normalized = normalize(frame, percentiles=(10.0, 90.0))
1650
+ >>> normalized
1651
+
1652
+ array([[0.33333334, 0.44444445, 0.5555556 ],
1653
+ [0.6666667 , 0.7777778 , 0.8888889 ],
1654
+ [1. , 1.1111112 , 1.2222222 ]], dtype=float32)
1655
+
1656
+ """
1657
+
1658
+ frame = frame.astype(float)
1659
+
1660
+ if ignore_gray_value is not None:
1661
+ subframe = frame[frame!=ignore_gray_value]
1662
+ else:
1663
+ subframe = frame.copy()
1664
+
1665
+ if values is not None:
1666
+ mi = values[0]; ma = values[1]
1667
+ else:
1668
+ mi = np.nanpercentile(subframe.flatten(),percentiles[0],keepdims=True)
1669
+ ma = np.nanpercentile(subframe.flatten(),percentiles[1],keepdims=True)
1670
+
1671
+ frame0 = frame.copy()
1672
+ frame = normalize_mi_ma(frame0, mi, ma, clip=False, eps=1e-20, dtype=np.float32)
1673
+ if amplification is not None:
1674
+ frame *= amplification
1675
+ if clip:
1676
+ if amplification is None:
1677
+ amplification = 1.
1678
+ frame[frame>=amplification] = amplification
1679
+ frame[frame<=0.] = 0.
1680
+ if ignore_gray_value is not None:
1681
+ frame[np.where(frame0)==ignore_gray_value] = ignore_gray_value
1682
+
1683
+ return frame.copy().astype(dtype)
1684
+
1685
+ def normalize_multichannel(multichannel_frame, percentiles=None,
1686
+ values=None, ignore_gray_value=0., clip=False,
1687
+ amplification=None, dtype=float):
1688
+
1689
+ """
1690
+ Normalizes a multichannel frame by adjusting the intensity values of each channel based on specified percentiles,
1691
+ direct value ranges, or amplification factors, with options to ignore a specific gray value and to clip the output.
1692
+
1693
+ Parameters
1694
+ ----------
1695
+ multichannel_frame : ndarray
1696
+ The input multichannel image frame to be normalized, expected to be a 3-dimensional array where the last dimension
1697
+ represents the channels.
1698
+ percentiles : list of tuples or tuple, optional
1699
+ Percentile ranges (low, high) for each channel used to scale the intensity values. If a single tuple is provided,
1700
+ it is applied to all channels. If None, the default percentile range of (0., 99.99) is used for each channel.
1701
+ values : list of tuples or tuple, optional
1702
+ Direct value ranges (min, max) for each channel to scale the intensity values. If a single tuple is provided, it
1703
+ is applied to all channels. This parameter overrides `percentiles` if provided.
1704
+ ignore_gray_value : float, optional
1705
+ A specific gray value to ignore during normalization (default is 0.).
1706
+ clip : bool, optional
1707
+ If True, clips the output values to the range [0, 1] or the specified `dtype` range if `dtype` is not float
1708
+ (default is False).
1709
+ amplification : float, optional
1710
+ A factor by which to amplify the intensity values after normalization. If None, no amplification is applied.
1711
+ dtype : data-type, optional
1712
+ The desired data-type for the output normalized frame. The default is float, but other types can be specified
1713
+ to change the range of the output values.
1714
+
1715
+ Returns
1716
+ -------
1717
+ ndarray
1718
+ The normalized multichannel frame as a 3-dimensional array of the same shape as `multichannel_frame`.
1719
+
1720
+ Raises
1721
+ ------
1722
+ AssertionError
1723
+ If the input `multichannel_frame` does not have 3 dimensions, or if the length of `values` does not match the
1724
+ number of channels in `multichannel_frame`.
1725
+
1726
+ Notes
1727
+ -----
1728
+ - This function provides flexibility in normalization by allowing the use of percentile ranges, direct value ranges,
1729
+ or amplification factors.
1730
+ - The function makes a copy of the input frame to avoid altering the original data.
1731
+ - When both `percentiles` and `values` are provided, `values` takes precedence for normalization.
1732
+
1733
+ Examples
1734
+ --------
1735
+ >>> multichannel_frame = np.random.rand(100, 100, 3) # Example multichannel frame
1736
+ >>> normalized_frame = normalize_multichannel(multichannel_frame, percentiles=((1, 99), (2, 98), (0, 100)))
1737
+ # Normalizes each channel of the frame using specified percentile ranges.
1738
+
1739
+ """
1740
+
1741
+ mf = multichannel_frame.copy().astype(float)
1742
+ assert mf.ndim==3,f'Wrong shape for the multichannel frame: {mf.shape}.'
1743
+ if percentiles is None:
1744
+ percentiles = [(0.,99.99)]*mf.shape[-1]
1745
+ elif isinstance(percentiles,tuple):
1746
+ percentiles = [percentiles]*mf.shape[-1]
1747
+ if values is not None:
1748
+ if isinstance(values, tuple):
1749
+ values = [values]*mf.shape[-1]
1750
+ assert len(values)==mf.shape[-1],'Mismatch between the normalization values provided and the number of channels.'
1751
+
1752
+ for c in range(mf.shape[-1]):
1753
+ if values is not None:
1754
+ v = values[c]
1755
+ else:
1756
+ v = None
1757
+ mf[:,:,c] = normalize(mf[:,:,c].copy(),
1758
+ percentiles=percentiles[c],
1759
+ values=v,
1760
+ ignore_gray_value=ignore_gray_value,
1761
+ clip=clip,
1762
+ amplification=amplification,
1763
+ dtype=dtype,
1764
+ )
1765
+ return mf
1766
+
1767
+ def load_frames(img_nums, stack_path, scale=None, normalize_input=True, dtype=float, normalize_kwargs={"percentiles": (0.,99.99)}):
1768
+
1769
+ """
1770
+ Loads and optionally normalizes and rescales specified frames from a stack located at a given path.
1771
+
1772
+ This function reads specified frames from a stack file, applying systematic adjustments to ensure
1773
+ the channel axis is last. It supports optional normalization of the input frames and rescaling. An
1774
+ artificial pixel modification is applied to frames with uniform values to prevent errors during
1775
+ normalization.
1776
+
1777
+ Parameters
1778
+ ----------
1779
+ img_nums : int or list of int
1780
+ The index (or indices) of the image frame(s) to load from the stack.
1781
+ stack_path : str
1782
+ The file path to the stack from which frames are to be loaded.
1783
+ scale : float, optional
1784
+ The scaling factor to apply to the frames. If None, no scaling is applied (default is None).
1785
+ normalize_input : bool, optional
1786
+ Whether to normalize the loaded frames. If True, normalization is applied according to
1787
+ `normalize_kwargs` (default is True).
1788
+ dtype : data-type, optional
1789
+ The desired data-type for the output frames (default is float).
1790
+ normalize_kwargs : dict, optional
1791
+ Keyword arguments to pass to the normalization function (default is {"percentiles": (0., 99.99)}).
1792
+
1793
+ Returns
1794
+ -------
1795
+ ndarray or None
1796
+ The loaded, and possibly normalized and rescaled, frames as a NumPy array. Returns None if there
1797
+ is an error in loading the frames.
1798
+
1799
+ Raises
1800
+ ------
1801
+ Exception
1802
+ Prints an error message if the specified frames cannot be loaded or if there is a mismatch between
1803
+ the provided experiment channel information and the stack format.
1804
+
1805
+ Notes
1806
+ -----
1807
+ - The function uses scikit-image for reading frames and supports multi-frame TIFF stacks.
1808
+ - Normalization and scaling are optional and can be customized through function parameters.
1809
+ - A workaround is implemented for frames with uniform pixel values to prevent normalization errors by
1810
+ adding a 'fake' pixel.
1811
+
1812
+ Examples
1813
+ --------
1814
+ >>> frames = load_frames([0, 1, 2], '/path/to/stack.tif', scale=0.5, normalize_input=True, dtype=np.uint8)
1815
+ # Loads the first three frames from '/path/to/stack.tif', normalizes them, rescales by a factor of 0.5,
1816
+ # and converts them to uint8 data type.
1817
+
1818
+ """
1819
+
1820
+ try:
1821
+ frames = skio.imread(stack_path, img_num=img_nums, plugin="tifffile")
1822
+ except Exception as e:
1823
+ print(f'Error in loading the frame {img_nums} {e}. Please check that the experiment channel information is consistent with the movie being read.')
1824
+ return None
1825
+
1826
+ if frames.ndim==3:
1827
+ # Systematically move channel axis to the end
1828
+ channel_axis = np.argmin(frames.shape)
1829
+ frames = np.moveaxis(frames, channel_axis, -1)
1830
+ if frames.ndim==2:
1831
+ frames = frames[:,:,np.newaxis]
1832
+ if normalize_input:
1833
+ frames = normalize_multichannel(frames, **normalize_kwargs)
1834
+ if scale is not None:
1835
+ frames = zoom(frames, [scale,scale,1], order=3)
1836
+
1837
+ # add a fake pixel to prevent auto normalization errors on images that are uniform
1838
+ # to revisit
1839
+ for k in range(frames.shape[2]):
1840
+ unique_values = np.unique(frames[:,:,k])
1841
+ if len(unique_values)==1:
1842
+ frames[0,0,k] += 1
1843
+
1844
+ return frames.astype(dtype)
1845
+
1846
+
1847
+ def get_stack_normalization_values(stack, percentiles=None, ignore_gray_value=0.):
1848
+
1849
+ """
1850
+ Computes the normalization value ranges (minimum and maximum) for each channel in a 4D stack based on specified percentiles.
1851
+
1852
+ This function calculates the value ranges for normalizing each channel within a 4-dimensional stack, with dimensions
1853
+ expected to be in the order of Time (T), Y (height), X (width), and Channels (C). The normalization values are determined
1854
+ by the specified percentiles for each channel. An option to ignore a specific gray value during computation is provided,
1855
+ though its effect is not implemented in this snippet.
1856
+
1857
+ Parameters
1858
+ ----------
1859
+ stack : ndarray
1860
+ The input 4D stack with dimensions TYXC from which to calculate normalization values.
1861
+ percentiles : tuple, list of tuples, optional
1862
+ The percentile values (low, high) used to calculate the normalization ranges for each channel. If a single tuple
1863
+ is provided, it is applied to all channels. If a list of tuples is provided, each tuple is applied to the
1864
+ corresponding channel. If None, defaults to (0., 99.99) for each channel.
1865
+ ignore_gray_value : float, optional
1866
+ A gray value to potentially ignore during the calculation. This parameter is provided for interface consistency
1867
+ but is not utilized in the current implementation (default is 0.).
1868
+
1869
+ Returns
1870
+ -------
1871
+ list of tuples
1872
+ A list where each tuple contains the (minimum, maximum) values for normalizing each channel based on the specified
1873
+ percentiles.
1874
+
1875
+ Raises
1876
+ ------
1877
+ AssertionError
1878
+ If the input stack does not have 4 dimensions, or if the length of the `percentiles` list does not match the number
1879
+ of channels in the stack.
1880
+
1881
+ Notes
1882
+ -----
1883
+ - The function assumes the input stack is in TYXC format, where T is the time dimension, Y and X are spatial dimensions,
1884
+ and C is the channel dimension.
1885
+ - Memory management via `gc.collect()` is employed after calculating normalization values for each channel to mitigate
1886
+ potential memory issues with large datasets.
1887
+
1888
+ Examples
1889
+ --------
1890
+ >>> stack = np.random.rand(5, 100, 100, 3) # Example 4D stack with 3 channels
1891
+ >>> normalization_values = get_stack_normalization_values(stack, percentiles=((1, 99), (2, 98), (0, 100)))
1892
+ # Calculates normalization ranges for each channel using the specified percentiles.
1893
+
1894
+ """
1895
+
1896
+ assert stack.ndim==4,f'Wrong number of dimensions for the stack, expect TYXC (4) got {stack.ndim}.'
1897
+ if percentiles is None:
1898
+ percentiles = [(0.,99.99)]*stack.shape[-1]
1899
+ elif isinstance(percentiles,tuple):
1900
+ percentiles = [percentiles]*stack.shape[-1]
1901
+ elif isinstance(percentiles,list):
1902
+ assert len(percentiles)==stack.shape[-1],f'Mismatch between the provided percentiles and the number of channels {stack.shape[-1]}. If you meant to apply the same percentiles to all channels, please provide a single tuple.'
1903
+
1904
+ values = []
1905
+ for c in range(stack.shape[-1]):
1906
+ perc = percentiles[c]
1907
+ mi = np.nanpercentile(stack[:,:,:,c].flatten(),perc[0],keepdims=True)[0]
1908
+ ma = np.nanpercentile(stack[:,:,:,c].flatten(),perc[1],keepdims=True)[0]
1909
+ values.append(tuple((mi,ma)))
1910
+ gc.collect()
1911
+
1912
+ return values
1913
+
1914
+
1915
+ def get_positions_in_well(well):
1916
+
1917
+ """
1918
+ Retrieves the list of position directories within a specified well directory,
1919
+ formatted as a NumPy array of strings.
1920
+
1921
+ This function identifies position directories based on their naming convention,
1922
+ which must include a numeric identifier following the well's name. The well's name
1923
+ is expected to start with 'W' (e.g., 'W1'), followed by a numeric identifier. Position
1924
+ directories are assumed to be named with this numeric identifier directly after the well
1925
+ identifier, without the 'W'. For example, positions within well 'W1' might be named
1926
+ '101', '102', etc. This function will glob these directories and return their full
1927
+ paths as a NumPy array.
1928
+
1929
+ Parameters
1930
+ ----------
1931
+ well : str
1932
+ The path to the well directory from which to retrieve position directories.
1933
+
1934
+ Returns
1935
+ -------
1936
+ np.ndarray
1937
+ An array of strings, each representing the full path to a position directory within
1938
+ the specified well. The array is empty if no position directories are found.
1939
+
1940
+ Notes
1941
+ -----
1942
+ - This function relies on a specific naming convention for wells and positions. It assumes
1943
+ that each well directory is prefixed with 'W' followed by a numeric identifier, and
1944
+ position directories are named starting with this numeric identifier directly.
1945
+
1946
+ Examples
1947
+ --------
1948
+ >>> get_positions_in_well('/path/to/experiment/W1')
1949
+ # This might return an array like array(['/path/to/experiment/W1/101', '/path/to/experiment/W1/102'])
1950
+ if position directories '101' and '102' exist within the well 'W1' directory.
1951
+
1952
+ """
1953
+
1954
+ if well.endswith(os.sep):
1955
+ well = well[:-1]
1956
+
1957
+ w_numeric = os.path.split(well)[-1].replace('W','')
1958
+ positions = glob(os.sep.join([well,f'{w_numeric}*{os.sep}']))
1959
+
1960
+ return np.array(positions,dtype=str)
1961
+
1962
+
1963
+ def extract_experiment_folder_output(experiment_folder, destination_folder):
1964
+
1965
+ """
1966
+ Copies the output subfolder and associated tables from an experiment folder to a new location,
1967
+ making the experiment folder much lighter by only keeping essential data.
1968
+
1969
+ This function takes the path to an experiment folder and a destination folder as input.
1970
+ It creates a copy of the experiment folder at the destination, but only includes the output subfolders
1971
+ and their associated tables for each well and position within the experiment.
1972
+ This operation significantly reduces the size of the experiment data by excluding non-essential files.
1973
+
1974
+ The structure of the copied experiment folder is preserved, including the configuration file,
1975
+ well directories, and position directories within each well.
1976
+ Only the 'output' subfolder and its 'tables' subdirectory are copied for each position.
1977
+
1978
+ Parameters
1979
+ ----------
1980
+ experiment_folder : str
1981
+ The path to the source experiment folder from which to extract data.
1982
+ destination_folder : str
1983
+ The path to the destination folder where the reduced copy of the experiment
1984
+ will be created.
1985
+
1986
+ Notes
1987
+ -----
1988
+ - This function assumes that the structure of the experiment folder is consistent,
1989
+ with wells organized in subdirectories and each containing a position subdirectory.
1990
+ Each position subdirectory should have an 'output' folder and a 'tables' subfolder within it.
1991
+
1992
+ - The function also assumes the existence of a configuration file in the root of the
1993
+ experiment folder, which is copied to the root of the destination experiment folder.
1994
+
1995
+ Examples
1996
+ --------
1997
+ >>> extract_experiment_folder_output('/path/to/experiment_folder', '/path/to/destination_folder')
1998
+ # This will copy the 'experiment_folder' to 'destination_folder', including only
1999
+ # the output subfolders and their tables for each well and position.
2000
+
2001
+ """
2002
+
2003
+
2004
+ if experiment_folder.endswith(os.sep):
2005
+ experiment_folder = experiment_folder[:-1]
2006
+ if destination_folder.endswith(os.sep):
2007
+ destination_folder = destination_folder[:-1]
2008
+
2009
+ exp_name = experiment_folder.split(os.sep)[-1]
2010
+ output_path = os.sep.join([destination_folder, exp_name])
2011
+ if not os.path.exists(output_path):
2012
+ os.mkdir(output_path)
2013
+
2014
+ config = get_config(experiment_folder)
2015
+ copyfile(config,os.sep.join([output_path,os.path.split(config)[-1]]))
2016
+
2017
+ wells_src = get_experiment_wells(experiment_folder)
2018
+ wells = [w.split(os.sep)[-2] for w in wells_src]
2019
+
2020
+ for k,w in enumerate(wells):
2021
+
2022
+ well_output_path = os.sep.join([output_path,w])
2023
+ if not os.path.exists(well_output_path):
2024
+ os.mkdir(well_output_path)
2025
+
2026
+ positions = get_positions_in_well(wells_src[k])
2027
+
2028
+ for pos in positions:
2029
+ pos_name = extract_position_name(pos)
2030
+ output_pos = os.sep.join([well_output_path, pos_name])
2031
+ if not os.path.exists(output_pos):
2032
+ os.mkdir(output_pos)
2033
+ output_folder = os.sep.join([output_pos, 'output'])
2034
+ output_tables_folder = os.sep.join([output_folder, 'tables'])
2035
+
2036
+ if not os.path.exists(output_folder):
2037
+ os.mkdir(output_folder)
2038
+
2039
+ if not os.path.exists(output_tables_folder):
2040
+ os.mkdir(output_tables_folder)
2041
+
2042
+ tab_path = glob(pos+os.sep.join(['output','tables',f'*']))
2043
+
2044
+ for t in tab_path:
2045
+ copyfile(t,os.sep.join([output_tables_folder,os.path.split(t)[-1]]))
2046
+
2047
+
2048
+
2049
+ if __name__ == '__main__':
2050
+ control_segmentation_napari("/home/limozin/Documents/Experiments/MinimumJan/W4/401/", prefix='Aligned', population="target", flush_memory=False)