fucciphase 0.0.1__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.
fucciphase/sensor.py ADDED
@@ -0,0 +1,454 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import List, Union
3
+
4
+ import numpy as np
5
+ import pandas as pd
6
+ from scipy import optimize
7
+
8
+
9
+ def logistic(
10
+ x: Union[float, np.ndarray], center: float, sigma: float, sign: float = 1.0
11
+ ) -> Union[float, np.ndarray]:
12
+ """Logistic function."""
13
+ tiny = 1.0e-15
14
+ arg = sign * (x - center) / max(tiny, sigma)
15
+ return 1.0 / (1.0 + np.exp(arg))
16
+
17
+
18
+ def accumulation_function(
19
+ x: Union[float, np.ndarray],
20
+ center: float,
21
+ sigma: float,
22
+ offset_intensity: float = 0,
23
+ ) -> Union[float, np.ndarray]:
24
+ """Function to describe accumulation of sensor."""
25
+ return 1.0 - logistic(x, center, sigma) - offset_intensity
26
+
27
+
28
+ def degradation_function(
29
+ x: Union[float, np.ndarray],
30
+ center: float,
31
+ sigma: float,
32
+ offset_intensity: float = 0,
33
+ ) -> Union[float, np.ndarray]:
34
+ """Function to describe degradation of sensor."""
35
+ return 1.0 - logistic(x, center, sigma, sign=-1.0) - offset_intensity
36
+
37
+
38
+ class FUCCISensor(ABC):
39
+ """Base class for a FUCCI sensor."""
40
+
41
+ @abstractmethod
42
+ def __init__(
43
+ self,
44
+ phase_percentages: List[float],
45
+ center: List[float],
46
+ sigma: List[float],
47
+ ) -> None:
48
+ pass
49
+
50
+ @property
51
+ @abstractmethod
52
+ def fluorophores(self) -> int:
53
+ """Number of fluorophores."""
54
+ pass
55
+
56
+ @property
57
+ @abstractmethod
58
+ def phases(self) -> List[str]:
59
+ """Function to hard-code the supported phases of a sensor."""
60
+ pass
61
+
62
+ @property
63
+ def phase_percentages(self) -> List[float]:
64
+ """Percentage of individual phases."""
65
+ return self._phase_percentages
66
+
67
+ @phase_percentages.setter
68
+ def phase_percentages(self, values: List[float]) -> None:
69
+ if len(values) != len(self.phases):
70
+ raise ValueError("Pass percentage for each phase.")
71
+
72
+ # check that the sum of phase borders is less than 100
73
+ if not np.isclose(sum(values), 100.0, atol=0.2):
74
+ raise ValueError("Phase percentages do not sum to 100.")
75
+
76
+ self._phase_percentages = values
77
+
78
+ @abstractmethod
79
+ def get_phase(self, phase_markers: Union[List[bool], "pd.Series[bool]"]) -> str:
80
+ """Get the discrete phase based on phase markers.
81
+
82
+ Notes
83
+ -----
84
+ Discrete phase refers to, for example, G1 or S phase.
85
+ The phase_markers must match the number of used fluorophores.
86
+ """
87
+ pass
88
+
89
+ @abstractmethod
90
+ def get_estimated_cycle_percentage(
91
+ self, phase: str, intensities: List[float]
92
+ ) -> float:
93
+ """Estimate percentage based on sensor intensities."""
94
+ pass
95
+
96
+ def set_accumulation_and_degradation_parameters(
97
+ self, center: List[float], sigma: List[float]
98
+ ) -> None:
99
+ """Pass list of functions for logistic functions.
100
+
101
+ Parameters
102
+ ----------
103
+ center: List[float]
104
+ List of center values for accumulation and degradation curves.
105
+ sigma: List[float]
106
+ List of width values for accumulation and degradation curves.
107
+ """
108
+ if len(center) != 2 * self.fluorophores:
109
+ raise ValueError("Need to supply 2 center values per fluorophore.")
110
+ if len(sigma) != 2 * self.fluorophores:
111
+ raise ValueError("Need to supply 2 width values per fluorophore.")
112
+ self._center_values = center
113
+ self._sigma_values = sigma
114
+
115
+ @abstractmethod
116
+ def get_expected_intensities(
117
+ self, percentage: Union[float, np.ndarray]
118
+ ) -> List[Union[float, np.ndarray]]:
119
+ """Return value of calibrated curves."""
120
+ pass
121
+
122
+
123
+ class FUCCISASensor(FUCCISensor):
124
+ """FUCCI(SA) sensor."""
125
+
126
+ def __init__(
127
+ self, phase_percentages: List[float], center: List[float], sigma: List[float]
128
+ ) -> None:
129
+ self.phase_percentages = phase_percentages
130
+ self.set_accumulation_and_degradation_parameters(center, sigma)
131
+
132
+ @property
133
+ def fluorophores(self) -> int:
134
+ """Number of fluorophores."""
135
+ return 2
136
+
137
+ @property
138
+ def phases(self) -> List[str]:
139
+ """Function to hard-code the supported phases of a sensor."""
140
+ return ["G1", "G1/S", "S/G2/M"]
141
+
142
+ def get_phase(self, phase_markers: Union[List[bool], "pd.Series[bool]"]) -> str:
143
+ """Return the discrete phase based channel ON / OFF data for the
144
+ FUCCI(SA) sensor.
145
+ """
146
+ if not len(phase_markers) == 2:
147
+ raise ValueError(
148
+ "The markers for G1 and S/G2/M channel haveto be provided!"
149
+ )
150
+ g1_on = phase_markers[0]
151
+ s_g2_on = phase_markers[1]
152
+ # low intensity at the very beginning of cycle
153
+ if not g1_on and not s_g2_on:
154
+ return "G1"
155
+ elif g1_on and not s_g2_on:
156
+ return "G1"
157
+ elif not g1_on and s_g2_on:
158
+ return "S/G2/M"
159
+ # G1/S transition phase
160
+ else:
161
+ return "G1/S"
162
+
163
+ def _find_g1_percentage(self, intensity: float) -> float:
164
+ """Find percentage in G1 phase.
165
+
166
+ Parameters
167
+ ----------
168
+ intensity: float
169
+ Intensity of cyan / green channel
170
+
171
+ Notes
172
+ -----
173
+ Checks the accumulation function of the first colour.
174
+ First colour means the colour indicating G1 phase.
175
+
176
+ """
177
+ g1_perc = self.phase_percentages[0]
178
+ # intensity below expected minimal intensity
179
+ if intensity < accumulation_function(
180
+ 0, self._center_values[0], self._sigma_values[0]
181
+ ):
182
+ return 0.0
183
+ elif intensity > accumulation_function(
184
+ g1_perc, self._center_values[0], self._sigma_values[0]
185
+ ):
186
+ return g1_perc
187
+ return float(
188
+ optimize.bisect(
189
+ accumulation_function,
190
+ 0.0,
191
+ g1_perc,
192
+ args=(self._center_values[0], self._sigma_values[0], intensity),
193
+ )
194
+ )
195
+
196
+ def _find_g1s_percentage(self, intensity: float) -> float:
197
+ """Find percentage in G1/S phase.
198
+
199
+ Parameters
200
+ ----------
201
+ intensity: float
202
+ Intensity of cyan / green channel
203
+
204
+ Notes
205
+ -----
206
+ Checks the degradation function of the first colour.
207
+ First colour means the colour indicating G1 phase.
208
+ """
209
+ g1_perc = self.phase_percentages[0]
210
+ g1s_perc = self.phase_percentages[1]
211
+ if intensity > degradation_function(
212
+ g1_perc, self._center_values[1], self._sigma_values[1]
213
+ ):
214
+ return g1_perc
215
+ elif intensity < degradation_function(
216
+ g1_perc + g1s_perc, self._center_values[1], self._sigma_values[1]
217
+ ):
218
+ return g1_perc + g1s_perc
219
+ return float(
220
+ optimize.bisect(
221
+ degradation_function,
222
+ g1_perc,
223
+ g1_perc + g1s_perc,
224
+ args=(self._center_values[1], self._sigma_values[1], intensity),
225
+ )
226
+ )
227
+
228
+ def _find_sg2m_percentage(self, intensity: float) -> float:
229
+ """Find percentage in S/G2/M phase.
230
+
231
+ Parameters
232
+ ----------
233
+ intensity: float
234
+ Intensity of second colour (magenta / red)
235
+
236
+ Notes
237
+ -----
238
+ Checks the accumulation function of the second colour.
239
+ Second colour means the colour indicating S/G2/M phase.
240
+ """
241
+ g1_perc = self.phase_percentages[0]
242
+ g1s_perc = self.phase_percentages[1]
243
+
244
+ # check if intensity is below smallest expected intensity
245
+ if intensity < accumulation_function(
246
+ g1_perc + g1s_perc, self._center_values[2], self._sigma_values[2]
247
+ ):
248
+ return g1_perc + g1s_perc
249
+ # if intensity is very small, it is M phase
250
+ if intensity < 0.3 * accumulation_function(
251
+ 100.0, self._center_values[2], self._sigma_values[2]
252
+ ):
253
+ return 100.0
254
+ # return middle of interval if values are close
255
+ g1s_level = accumulation_function(
256
+ g1_perc + g1s_perc, self._center_values[2], self._sigma_values[2]
257
+ )
258
+ final_level = accumulation_function(
259
+ 100.0, self._center_values[2], self._sigma_values[2]
260
+ )
261
+
262
+ if np.isclose(g1s_level, final_level):
263
+ return g1s_perc + 0.5 * (100.0 - g1s_perc - g1_perc)
264
+ try:
265
+ if np.greater_equal(intensity, final_level):
266
+ intensity = intensity - 2.0 * (intensity - final_level) # type: ignore[assignment]
267
+ return float(
268
+ optimize.bisect(
269
+ accumulation_function,
270
+ g1_perc + g1s_perc,
271
+ 100.0,
272
+ args=(self._center_values[2], self._sigma_values[2], intensity),
273
+ )
274
+ )
275
+ except ValueError:
276
+ print(
277
+ "WARNING: could not infer percentage in SG2M phase, using average phase"
278
+ )
279
+ return g1s_perc + 0.5 * (100.0 - g1s_perc - g1_perc)
280
+
281
+ def get_estimated_cycle_percentage(
282
+ self, phase: str, intensities: List[float]
283
+ ) -> float:
284
+ """Estimate a cell cycle percentage based on intensities.
285
+
286
+ Parameters
287
+ ----------
288
+ phase: str
289
+ Name of phase
290
+ intensities: List[float]
291
+ List of channel intensities for all fluorophores
292
+ """
293
+ if phase not in self.phases:
294
+ raise ValueError(f"Phase {phase} is not defined for this sensor.")
295
+ if phase == "G1":
296
+ return self._find_g1_percentage(intensities[0])
297
+ if phase == "G1/S":
298
+ return self._find_g1s_percentage(intensities[0])
299
+ else:
300
+ return self._find_sg2m_percentage(intensities[1])
301
+
302
+ def get_expected_intensities(
303
+ self, percentage: Union[float, np.ndarray]
304
+ ) -> List[Union[float, np.ndarray]]:
305
+ """Return value of calibrated curves."""
306
+ g1_acc = accumulation_function(
307
+ percentage, self._center_values[0], self._sigma_values[0]
308
+ )
309
+ g1_deg = degradation_function(
310
+ percentage, self._center_values[1], self._sigma_values[1]
311
+ )
312
+ s_g2_m_acc = accumulation_function(
313
+ percentage, self._center_values[2], self._sigma_values[2]
314
+ )
315
+ s_g2_m_deg = degradation_function(
316
+ percentage, self._center_values[3], self._sigma_values[3]
317
+ )
318
+ return [g1_acc + g1_deg - 1.0, s_g2_m_acc + s_g2_m_deg - 1.0]
319
+
320
+
321
+ def get_fuccisa_default_sensor() -> FUCCISASensor:
322
+ """Return sensor with default values.
323
+
324
+ Should only be used if the cell cycle percentage is not of interest.
325
+ """
326
+ return FUCCISASensor(
327
+ phase_percentages=[25, 25, 50], center=[0, 0, 0, 0], sigma=[0, 0, 0, 0]
328
+ )
329
+
330
+
331
+ class PIPFUCCISensor(FUCCISensor):
332
+ """PIP-FUCCI sensor."""
333
+
334
+ def __init__(
335
+ self, phase_percentages: List[float], center: List[float], sigma: List[float]
336
+ ) -> None:
337
+ self.phase_percentages = phase_percentages
338
+ self.set_accumulation_and_degradation_parameters(center, sigma)
339
+
340
+ @property
341
+ def fluorophores(self) -> int:
342
+ """Number of fluorophores."""
343
+ return 2
344
+
345
+ @property
346
+ def phases(self) -> List[str]:
347
+ """Function to hard-code the supported phases of a sensor."""
348
+ return ["G1", "S", "G2/M"]
349
+
350
+ def get_phase(self, phase_markers: Union[List[bool], "pd.Series[bool]"]) -> str:
351
+ """Return the discrete phase based channel ON / OFF data for the
352
+ FUCCI(SA) sensor.
353
+ """
354
+ if not len(phase_markers) == 2:
355
+ raise ValueError(
356
+ "The markers for G1 and S/G2/M channel haveto be provided!"
357
+ )
358
+ g1_on = phase_markers[0]
359
+ s_on = phase_markers[1]
360
+ # low intensity at the very beginning of cycle
361
+ if not g1_on and not s_on:
362
+ return "S"
363
+ elif g1_on and not s_on:
364
+ return "G1"
365
+ elif not g1_on and s_on:
366
+ return "S"
367
+ else:
368
+ return "G2/M"
369
+
370
+ def _find_g1_percentage(self, intensity: float) -> float:
371
+ """Find percentage in G1 phase.
372
+
373
+ Parameters
374
+ ----------
375
+ intensity: float
376
+ Intensity of cyan / green channel
377
+
378
+ Notes
379
+ -----
380
+ Checks the accumulation function of the first colour.
381
+ First colour means the colour indicating G1 phase.
382
+
383
+ """
384
+ raise NotImplementedError("Percentage estimate not yet implemented!")
385
+
386
+ def _find_s_percentage(self, intensity: float) -> float:
387
+ """Find percentage in S phase.
388
+
389
+ Parameters
390
+ ----------
391
+ intensity: float
392
+ Intensity of cyan / green channel
393
+
394
+ Notes
395
+ -----
396
+ Checks the degradation function of the first colour.
397
+ First colour means the colour indicating G1 phase.
398
+ """
399
+ raise NotImplementedError("Percentage estimate not yet implemented!")
400
+
401
+ def _find_g2m_percentage(self, intensity: float) -> float:
402
+ """Find percentage in G2/M phase.
403
+
404
+ Parameters
405
+ ----------
406
+ intensity: float
407
+ Intensity of second colour (magenta / red)
408
+
409
+ Notes
410
+ -----
411
+ Checks the accumulation function of the second colour.
412
+ Second colour means the colour indicating S/G2/M phase.
413
+ """
414
+ raise NotImplementedError("Percentage estimate not yet implemented!")
415
+
416
+ def get_estimated_cycle_percentage(
417
+ self, phase: str, intensities: List[float]
418
+ ) -> float:
419
+ """Estimate a cell cycle percentage based on intensities.
420
+
421
+ Parameters
422
+ ----------
423
+ phase: str
424
+ Name of phase
425
+ intensities: List[float]
426
+ List of channel intensities for all fluorophores
427
+ """
428
+ raise NotImplementedError("Percentage estimate not yet implemented!")
429
+ if phase not in self.phases:
430
+ raise ValueError(f"Phase {phase} is not defined for this sensor.")
431
+ # TODO fill the following structure with life!
432
+ if phase == "G1":
433
+ return self._find_g1_percentage(intensities[0])
434
+ if phase == "S":
435
+ return self._find_s_percentage(intensities[0])
436
+ else:
437
+ return self._find_g2m_percentage(intensities[1])
438
+
439
+ def get_expected_intensities(
440
+ self, percentage: Union[float, np.ndarray]
441
+ ) -> List[Union[float, np.ndarray]]:
442
+ """Return value of calibrated curves."""
443
+ raise NotImplementedError("Intensity estimate not yet implemented!")
444
+
445
+
446
+ def get_pipfucci_default_sensor() -> PIPFUCCISensor:
447
+ """Return sensor with default values.
448
+
449
+ Should only be used if the cell cycle percentage is not of interest.
450
+ """
451
+ # TODO update values
452
+ return PIPFUCCISensor(
453
+ phase_percentages=[25, 25, 50], center=[0, 0, 0, 0], sigma=[0, 0, 0, 0]
454
+ )
@@ -0,0 +1,81 @@
1
+ import pandas as pd
2
+
3
+
4
+ def get_feature_value_at_frame(
5
+ labels: pd.DataFrame, label_name: str, label: int, feature: str
6
+ ) -> float:
7
+ """Helper function to get value of feature."""
8
+ value = labels[labels[label_name] == label, feature].to_numpy()
9
+ assert len(value) == 1
10
+ return float(value[0])
11
+
12
+
13
+ def prepare_penalty_df(
14
+ df: pd.DataFrame,
15
+ feature_1: str,
16
+ feature_2: str,
17
+ frame_name: str = "FRAME",
18
+ label_name: str = "LABEL",
19
+ weight: float = 1.0,
20
+ ) -> pd.DataFrame:
21
+ """Prepare a DF with penalties for tracking.
22
+
23
+ Notes
24
+ -----
25
+ See more details here:
26
+ https://laptrack.readthedocs.io/en/stable/examples/custom_metric.html
27
+
28
+ The penalty formulation is similar to TrackMate,
29
+ see
30
+ https://imagej.net/plugins/trackmate/trackers/lap-trackers#calculating-linking-costs
31
+ The penalty is computed as:
32
+ P = 1 + sum(feature_penalties)
33
+ Each feature penalty is:
34
+ p = 3 * weight * abs(f1 - f2) / (f1 + f2)
35
+
36
+ """
37
+ raise NotImplementedError("This function is not yet stably implemented.")
38
+
39
+ penalty_records = []
40
+ frames = df[frame_name].unique()
41
+ for i, frame in enumerate(frames):
42
+ # skip last frame
43
+ if i == len(frames) - 1:
44
+ continue
45
+ next_frame = frames[i + 1]
46
+ labels = df.loc[df[frame_name] == frame, label_name]
47
+ next_labels = df.loc[df[frame_name] == next_frame, label_name]
48
+ for label in labels:
49
+ if label == 0:
50
+ continue
51
+ # get index where frame + label
52
+ value1 = get_feature_value_at_frame(labels, label_name, label, feature_1)
53
+ value2 = get_feature_value_at_frame(labels, label_name, label, feature_2)
54
+ for next_label in labels:
55
+ if next_label == 0:
56
+ continue
57
+
58
+ next_value1 = get_feature_value_at_frame(
59
+ next_labels, label_name, label, feature_1
60
+ )
61
+ next_value2 = get_feature_value_at_frame(
62
+ next_labels, label_name, label, feature_2
63
+ )
64
+ penalty = (
65
+ 3.0 * weight * abs(value1 - next_value1) / (value1 + next_value1)
66
+ )
67
+ penalty += (
68
+ 3.0 * weight * abs(value2 - next_value2) / (value2 + next_value2)
69
+ )
70
+ penalty += 1
71
+ penalty_records.append(
72
+ {
73
+ "frame": frame,
74
+ "label1": label,
75
+ "label2": next_label,
76
+ "penalty": penalty,
77
+ }
78
+ )
79
+ penalty_df = pd.DataFrame.from_records(penalty_records)
80
+
81
+ return penalty_df.set_index(["frame", "label1", "label2"]).copy()
@@ -0,0 +1,35 @@
1
+ """Convenience functions for fucciphase."""
2
+
3
+ __all__ = [
4
+ "TrackMateXML",
5
+ "check_channels",
6
+ "check_thresholds",
7
+ "compute_motility_parameters",
8
+ "export_lineage_tree_to_svg",
9
+ "fit_percentages",
10
+ "get_norm_channel_name",
11
+ "get_time_distortion_coefficient",
12
+ "norm",
13
+ "normalize_channels",
14
+ "plot_trackscheme",
15
+ "postprocess_estimated_percentages",
16
+ "simulate_single_track",
17
+ "split_all_tracks",
18
+ "split_track",
19
+ "split_trackmate_tracks",
20
+ ]
21
+
22
+ from .checks import check_channels, check_thresholds
23
+ from .dtw import get_time_distortion_coefficient
24
+ from .normalize import get_norm_channel_name, norm, normalize_channels
25
+ from .phase_fit import fit_percentages, postprocess_estimated_percentages
26
+ from .simulator import simulate_single_track
27
+ from .track_postprocessing import (
28
+ compute_motility_parameters,
29
+ export_lineage_tree_to_svg,
30
+ plot_trackscheme,
31
+ split_all_tracks,
32
+ split_track,
33
+ split_trackmate_tracks,
34
+ )
35
+ from .trackmate import TrackMateXML
@@ -0,0 +1,16 @@
1
+ from typing import List
2
+
3
+
4
+ def check_channels(n_fluorophores: int, channels: List[str]) -> None:
5
+ """Check number of channels."""
6
+ if len(channels) != n_fluorophores:
7
+ raise ValueError(f"Need to provide {n_fluorophores} channel names.")
8
+
9
+
10
+ def check_thresholds(n_fluorophores: int, thresholds: List[float]) -> None:
11
+ """Check correct format and range of thresholds."""
12
+ if len(thresholds) != n_fluorophores:
13
+ raise ValueError("Provide one threshold per channel.")
14
+ # check that the thresholds are between 0 and 1
15
+ if not all(0 < t < 1 for t in thresholds):
16
+ raise ValueError("Thresholds must be between 0 and 1.")
@@ -0,0 +1,59 @@
1
+ from typing import List, Union
2
+
3
+ import numpy as np
4
+
5
+
6
+ def get_time_distortion_coefficient(
7
+ path: Union[np.ndarray, List[List[float]]],
8
+ ) -> tuple[np.ndarray, float, int, int]:
9
+ """Compute distortion coefficient from warping path.
10
+
11
+ Parameters
12
+ ----------
13
+ path: np.ndarray
14
+ Warping path, first dimension query index, second reference index
15
+
16
+ The warping path holds two indices: the index of the query (first entry)
17
+ and the index of the reference curve (second entry)
18
+
19
+ """
20
+ lmbd = np.zeros(len(path) - 1)
21
+ alpha = 0
22
+ beta = 0
23
+ p: Union[np.ndarray, List[float]]
24
+ for idx, p in enumerate(path):
25
+ # first index is skipped
26
+ if idx == 0:
27
+ continue
28
+ # stretch check
29
+ if p[0] == path[idx - 1][0]:
30
+ beta += 1
31
+ else:
32
+ # end beta count, add lambdas
33
+ if beta > 0:
34
+ beta += 1
35
+ lmbd[idx - beta - 1 : idx - 1] = 1.0 - 1.0 / beta
36
+ beta = 0
37
+ # compression check
38
+ if p[1] == path[idx - 1][1]:
39
+ alpha += 1
40
+ else:
41
+ if alpha > 0:
42
+ alpha += 1
43
+ lmbd[idx - alpha : idx - 1] = 1.0 - alpha
44
+ alpha = 0
45
+
46
+ # check final entry
47
+ if beta > 0:
48
+ beta += 1
49
+ lmbd[idx - beta : idx] = 1.0 - 1.0 / beta
50
+ beta = 0
51
+ if alpha > 0:
52
+ alpha += 1
53
+ lmbd[idx - alpha + 1 : idx] = 1.0 - alpha
54
+ alpha = 0
55
+
56
+ distortion_score = np.sum(np.abs(lmbd))
57
+ compress_count = int(np.count_nonzero(lmbd > 0))
58
+ stretch_count = int(np.count_nonzero(lmbd < 0))
59
+ return lmbd, distortion_score, compress_count, stretch_count