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/__init__.py +12 -0
- fucciphase/fucci_phase.py +178 -0
- fucciphase/io.py +67 -0
- fucciphase/napari/__init__.py +5 -0
- fucciphase/napari/tracks_to_napari.py +117 -0
- fucciphase/phase.py +501 -0
- fucciphase/plot.py +548 -0
- fucciphase/py.typed +5 -0
- fucciphase/sensor.py +454 -0
- fucciphase/tracking_utilities.py +81 -0
- fucciphase/utils/__init__.py +35 -0
- fucciphase/utils/checks.py +16 -0
- fucciphase/utils/dtw.py +59 -0
- fucciphase/utils/normalize.py +202 -0
- fucciphase/utils/phase_fit.py +47 -0
- fucciphase/utils/simulator.py +85 -0
- fucciphase/utils/track_postprocessing.py +454 -0
- fucciphase/utils/trackmate.py +295 -0
- fucciphase-0.0.1.dist-info/METADATA +137 -0
- fucciphase-0.0.1.dist-info/RECORD +22 -0
- fucciphase-0.0.1.dist-info/WHEEL +4 -0
- fucciphase-0.0.1.dist-info/licenses/LICENSE +29 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
from typing import List, Optional, Tuple, Union
|
|
2
|
+
|
|
3
|
+
import numpy as np
|
|
4
|
+
import pandas as pd
|
|
5
|
+
from scipy import signal
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_norm_channel_name(channel: str) -> str:
|
|
9
|
+
"""Return the name of the normalized channel.
|
|
10
|
+
|
|
11
|
+
Parameters
|
|
12
|
+
----------
|
|
13
|
+
channel : str
|
|
14
|
+
Name of the channel to normalize.
|
|
15
|
+
|
|
16
|
+
Returns
|
|
17
|
+
-------
|
|
18
|
+
str
|
|
19
|
+
Name of the normalized channel.
|
|
20
|
+
"""
|
|
21
|
+
return f"{channel}_NORM"
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def get_avg_channel_name(channel: str) -> str:
|
|
25
|
+
"""Return the name of the moving-averaged channel.
|
|
26
|
+
|
|
27
|
+
Parameters
|
|
28
|
+
----------
|
|
29
|
+
channel : str
|
|
30
|
+
Name of the channel to average using moving average.
|
|
31
|
+
|
|
32
|
+
Returns
|
|
33
|
+
-------
|
|
34
|
+
str
|
|
35
|
+
Name of the moving-averaged channel.
|
|
36
|
+
"""
|
|
37
|
+
return f"{channel}_AVG"
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def norm(vector: Union[pd.Series, np.ndarray]) -> Union[pd.Series, np.ndarray]:
|
|
41
|
+
"""Normalize a vector by subtracting the min and dividing by (max - min).
|
|
42
|
+
|
|
43
|
+
Parameters
|
|
44
|
+
----------
|
|
45
|
+
vector : Union[pd.Series, np.ndarray]
|
|
46
|
+
Vector to normalize.
|
|
47
|
+
|
|
48
|
+
Returns
|
|
49
|
+
-------
|
|
50
|
+
Union[pd.Series, np.ndarray]
|
|
51
|
+
Normalized vector.
|
|
52
|
+
"""
|
|
53
|
+
max_ch = vector.max()
|
|
54
|
+
min_ch = vector.min()
|
|
55
|
+
norm_ch = np.round(
|
|
56
|
+
(vector - min_ch) / (max_ch - min_ch),
|
|
57
|
+
2, # number of decimals
|
|
58
|
+
)
|
|
59
|
+
|
|
60
|
+
return norm_ch
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
# flake8: noqa: C901
|
|
64
|
+
def normalize_channels(
|
|
65
|
+
df: pd.DataFrame,
|
|
66
|
+
channels: Union[str, List[str]],
|
|
67
|
+
use_moving_average: bool = True,
|
|
68
|
+
moving_average_window: int = 7,
|
|
69
|
+
manual_min: Optional[List[float]] = None,
|
|
70
|
+
manual_max: Optional[List[float]] = None,
|
|
71
|
+
track_id_name: str = "TRACK_ID",
|
|
72
|
+
) -> List[str]:
|
|
73
|
+
"""Normalize channels, add in place the resulting columns to the
|
|
74
|
+
dataframe, and return the new columns' name.
|
|
75
|
+
|
|
76
|
+
A moving average can be applied to each individual track before normalization.
|
|
77
|
+
|
|
78
|
+
Normalization is performed by inferring the min at the position of the maximum
|
|
79
|
+
of the other channel. Then, the min is subtracted and the result is divided
|
|
80
|
+
by (max - min).
|
|
81
|
+
These values are computed across all spots in each channel. Note that the resulting
|
|
82
|
+
normalized values are rounded to the 2nd decimal.
|
|
83
|
+
|
|
84
|
+
The min and max values can be provided manually. They should be determined by
|
|
85
|
+
imaging a large number of cells statically and computing the min and max values
|
|
86
|
+
observed.
|
|
87
|
+
This option is meant for static imaging. It is assumed that there are enough cells
|
|
88
|
+
in the image to have enough samples from each phase of the cell cycle.
|
|
89
|
+
|
|
90
|
+
Parameters
|
|
91
|
+
----------
|
|
92
|
+
df : pd.DataFrame
|
|
93
|
+
Dataframe
|
|
94
|
+
channels : Union[str, List[str]]
|
|
95
|
+
Name of the channels to normalize.
|
|
96
|
+
use_moving_average : bool
|
|
97
|
+
Whether to apply a moving average to each track before normalization.
|
|
98
|
+
moving_average_window : int
|
|
99
|
+
Size of the window used for the moving average, default 7.
|
|
100
|
+
manual_min : Optional[List[float]]
|
|
101
|
+
If provided, the minimum value to use for normalization.
|
|
102
|
+
manual_max : Optional[List[float]]
|
|
103
|
+
If provided, the maximum value to use for normalization.
|
|
104
|
+
track_id_name: str
|
|
105
|
+
Name of column with track IDs
|
|
106
|
+
|
|
107
|
+
Returns
|
|
108
|
+
-------
|
|
109
|
+
List[str]
|
|
110
|
+
Name of the new column(s).
|
|
111
|
+
|
|
112
|
+
Raises
|
|
113
|
+
------
|
|
114
|
+
ValueError
|
|
115
|
+
If the dataframe does not contain the mandatory columns.
|
|
116
|
+
"""
|
|
117
|
+
if not isinstance(channels, list):
|
|
118
|
+
channels = [channels]
|
|
119
|
+
|
|
120
|
+
if manual_min is not None:
|
|
121
|
+
# check that it has the same number of entries as there are channels
|
|
122
|
+
if len(manual_min) != len(channels):
|
|
123
|
+
raise ValueError(
|
|
124
|
+
f"Expected {len(channels)} values for manual_min, got {len(manual_min)}"
|
|
125
|
+
)
|
|
126
|
+
if manual_max is not None:
|
|
127
|
+
# check that it has the same number of entries as there are channels
|
|
128
|
+
if len(manual_max) != len(channels):
|
|
129
|
+
raise ValueError(
|
|
130
|
+
f"Expected {len(channels)} values for manual_max, got {len(manual_max)}"
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
# check that the dataframe contains the channel
|
|
134
|
+
new_columns = []
|
|
135
|
+
for channel in channels:
|
|
136
|
+
if channel not in df.columns:
|
|
137
|
+
raise ValueError(f"Column {channel} not found")
|
|
138
|
+
|
|
139
|
+
# compute the moving average for each track ID
|
|
140
|
+
if use_moving_average:
|
|
141
|
+
# apply moving average to each track ID
|
|
142
|
+
unique_track_IDs = df[track_id_name].unique()
|
|
143
|
+
|
|
144
|
+
avg_channel = get_avg_channel_name(channel)
|
|
145
|
+
for track_ID in unique_track_IDs:
|
|
146
|
+
index, ma = smooth_track(
|
|
147
|
+
df, track_ID, channel, track_id_name, moving_average_window
|
|
148
|
+
)
|
|
149
|
+
# update the dataframe by adding a new column
|
|
150
|
+
df.loc[index, avg_channel] = ma
|
|
151
|
+
|
|
152
|
+
# normalize channels
|
|
153
|
+
for channel in channels:
|
|
154
|
+
# moving average creates a new column with an own name
|
|
155
|
+
if use_moving_average:
|
|
156
|
+
avg_channel = get_avg_channel_name(channel)
|
|
157
|
+
else:
|
|
158
|
+
avg_channel = channel
|
|
159
|
+
# normalize channel
|
|
160
|
+
norm_ch = norm(df[avg_channel])
|
|
161
|
+
|
|
162
|
+
# add the new column
|
|
163
|
+
new_column = get_norm_channel_name(channel)
|
|
164
|
+
df[new_column] = norm_ch
|
|
165
|
+
new_columns.append(new_column)
|
|
166
|
+
|
|
167
|
+
return new_columns
|
|
168
|
+
|
|
169
|
+
|
|
170
|
+
def smooth_track(
|
|
171
|
+
df: pd.DataFrame,
|
|
172
|
+
track_ID: int,
|
|
173
|
+
channel: str,
|
|
174
|
+
track_id_name: str,
|
|
175
|
+
moving_average_window: int = 7,
|
|
176
|
+
) -> Tuple[pd.Index, np.ndarray]:
|
|
177
|
+
"""Smooth intensity in one channel for a single track.
|
|
178
|
+
|
|
179
|
+
Parameters
|
|
180
|
+
----------
|
|
181
|
+
df : pd.DataFrame
|
|
182
|
+
Dataframe
|
|
183
|
+
track_ID: int
|
|
184
|
+
Index of track
|
|
185
|
+
channel : st
|
|
186
|
+
Name of the channel to smooth
|
|
187
|
+
track_id_name: str
|
|
188
|
+
Name of column with track IDs
|
|
189
|
+
moving_average_window : int
|
|
190
|
+
Size of the window used for the moving average, default 7.
|
|
191
|
+
"""
|
|
192
|
+
# get the track
|
|
193
|
+
track: pd.DataFrame = df[df[track_id_name] == track_ID]
|
|
194
|
+
|
|
195
|
+
# compute the moving average
|
|
196
|
+
ma = signal.savgol_filter(
|
|
197
|
+
track[channel],
|
|
198
|
+
window_length=moving_average_window,
|
|
199
|
+
polyorder=3,
|
|
200
|
+
mode="nearest",
|
|
201
|
+
)
|
|
202
|
+
return track.index, ma
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from monotonic_derivative import ensure_monotonic_derivative
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def fit_percentages(frames: np.ndarray, percentages: np.ndarray) -> np.ndarray:
|
|
7
|
+
"""Fit estimated percentages to function with non-negative derivative."""
|
|
8
|
+
best_fit: np.ndarray = ensure_monotonic_derivative(
|
|
9
|
+
x=frames,
|
|
10
|
+
y=percentages,
|
|
11
|
+
degree=1,
|
|
12
|
+
force_negative_derivative=False,
|
|
13
|
+
)
|
|
14
|
+
# clip to range (0, 100)
|
|
15
|
+
return np.clip(best_fit, 0.0, 100.0)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def postprocess_estimated_percentages(
|
|
19
|
+
df: pd.DataFrame, percentage_column: str, track_id_name: str = "TRACK_ID"
|
|
20
|
+
) -> None:
|
|
21
|
+
"""Make estimated percentages continuous."""
|
|
22
|
+
if percentage_column not in df:
|
|
23
|
+
raise ValueError("The name of the percentage column is not in the DataFrame")
|
|
24
|
+
indices = df[track_id_name].unique()
|
|
25
|
+
postprocessed_percentage_column = percentage_column + "_POST"
|
|
26
|
+
df[postprocessed_percentage_column] = np.nan
|
|
27
|
+
for index in indices:
|
|
28
|
+
if index == -1:
|
|
29
|
+
continue
|
|
30
|
+
track = df[df[track_id_name] == index]
|
|
31
|
+
frames = track["FRAME"]
|
|
32
|
+
percentages = track[percentage_column]
|
|
33
|
+
if np.all(np.isnan(percentages)):
|
|
34
|
+
print("WARNING: No percentages to postprocess")
|
|
35
|
+
return
|
|
36
|
+
try:
|
|
37
|
+
restored_percentages = fit_percentages(frames, percentages)
|
|
38
|
+
except ValueError:
|
|
39
|
+
print(f"Error in track {index}")
|
|
40
|
+
print(
|
|
41
|
+
"Make sure that the spots belong to a unique track,"
|
|
42
|
+
" i.e., not more than one spot per frame per track."
|
|
43
|
+
)
|
|
44
|
+
print(track)
|
|
45
|
+
df.loc[df[track_id_name] == index, postprocessed_percentage_column] = (
|
|
46
|
+
restored_percentages
|
|
47
|
+
)
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
|
|
4
|
+
from fucciphase.sensor import FUCCISASensor
|
|
5
|
+
|
|
6
|
+
# TODO improve simulation, use logistic functions
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def simulate_single_channel(
|
|
10
|
+
t: np.ndarray, mean: float, sigma: float, amp: float = 1.0
|
|
11
|
+
) -> np.ndarray:
|
|
12
|
+
"""Simulate a single channel.
|
|
13
|
+
|
|
14
|
+
Parameters
|
|
15
|
+
----------
|
|
16
|
+
t : np.ndarray
|
|
17
|
+
Time vector
|
|
18
|
+
mean : float
|
|
19
|
+
Mean of the Gaussian
|
|
20
|
+
sigma : float
|
|
21
|
+
Standard deviation of the Gaussian
|
|
22
|
+
amp : float
|
|
23
|
+
Amplitude of the Gaussian
|
|
24
|
+
|
|
25
|
+
Returns
|
|
26
|
+
-------
|
|
27
|
+
np.ndarray
|
|
28
|
+
Intensity vector
|
|
29
|
+
"""
|
|
30
|
+
ch: np.ndarray = amp * np.exp(-((t - mean) ** 2) / (2 * sigma**2))
|
|
31
|
+
|
|
32
|
+
return np.round(ch, 2)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def simulate_single_track(track_id: float = 42, mean: float = 0.5) -> pd.DataFrame:
|
|
36
|
+
"""Simulate a single track.
|
|
37
|
+
|
|
38
|
+
Parameters
|
|
39
|
+
----------
|
|
40
|
+
track_id : int
|
|
41
|
+
Track ID
|
|
42
|
+
mean : float
|
|
43
|
+
Temporal mean corresponding to the crossing between the two channels
|
|
44
|
+
|
|
45
|
+
Returns
|
|
46
|
+
-------
|
|
47
|
+
pd.DataFrame
|
|
48
|
+
Dataframe mocking a Trackmate single track import.
|
|
49
|
+
"""
|
|
50
|
+
# example data
|
|
51
|
+
phase_percentages = [33.3, 33.3, 33.3]
|
|
52
|
+
center = [20.0, 55.0, 70.0, 95.0]
|
|
53
|
+
sigma = [5.0, 5.0, 10.0, 1.0]
|
|
54
|
+
# create sensor
|
|
55
|
+
sensor = FUCCISASensor(
|
|
56
|
+
phase_percentages=phase_percentages,
|
|
57
|
+
center=center,
|
|
58
|
+
sigma=sigma,
|
|
59
|
+
)
|
|
60
|
+
# create the time vector
|
|
61
|
+
percentage = np.arange(0, 50) / 50
|
|
62
|
+
t = 24 * percentage
|
|
63
|
+
percentage *= 100
|
|
64
|
+
|
|
65
|
+
# create the channels as Gaussian of time
|
|
66
|
+
ch1, ch2 = sensor.get_expected_intensities(percentage)
|
|
67
|
+
|
|
68
|
+
# create dataframe
|
|
69
|
+
df = pd.DataFrame(
|
|
70
|
+
{
|
|
71
|
+
"LABEL": [f"ID{i}" for i in range(len(t))],
|
|
72
|
+
"ID": list(range(len(t))),
|
|
73
|
+
"TRACK_ID": [track_id for _ in range(len(t))],
|
|
74
|
+
"POSITION_X": [np.round(mean * i * 0.02, 2) for i in range(len(t))],
|
|
75
|
+
"POSITION_Y": [np.round(mean * i * 0.3, 2) for i in range(len(t))],
|
|
76
|
+
"POSITION_T": [np.round(i * 0.01, 2) for i in range(len(t))],
|
|
77
|
+
"FRAME": list(range(len(t))),
|
|
78
|
+
"PERCENTAGE": percentage,
|
|
79
|
+
"MEAN_INTENSITY_CH1": [0 for _ in range(len(t))],
|
|
80
|
+
"MEAN_INTENSITY_CH2": [1 for _ in range(len(t))],
|
|
81
|
+
"MEAN_INTENSITY_CH3": ch1,
|
|
82
|
+
"MEAN_INTENSITY_CH4": ch2,
|
|
83
|
+
}
|
|
84
|
+
)
|
|
85
|
+
return df
|