flimkit 0.12.0__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.
- flimkit/FLIM/__init__.py +0 -0
- flimkit/FLIM/assemble.py +254 -0
- flimkit/FLIM/batch.py +681 -0
- flimkit/FLIM/bg_tools.py +51 -0
- flimkit/FLIM/fit_tools.py +244 -0
- flimkit/FLIM/fitters.py +1471 -0
- flimkit/FLIM/irf_tools.py +617 -0
- flimkit/FLIM/models.py +391 -0
- flimkit/GPU/__init__.py +85 -0
- flimkit/GPU/_base.py +391 -0
- flimkit/GPU/cuda.py +10 -0
- flimkit/GPU/mlx_backend.py +381 -0
- flimkit/GPU/mps.py +10 -0
- flimkit/GPU/rocm.py +10 -0
- flimkit/GPU/torch_backend.py +385 -0
- flimkit/UI/app_state.py +10 -0
- flimkit/UI/controller.py +139 -0
- flimkit/UI/expert_settings.py +248 -0
- flimkit/UI/fit_help.py +206 -0
- flimkit/UI/fov_preview.py +1085 -0
- flimkit/UI/gui.py +3919 -0
- flimkit/UI/icon.icns +0 -0
- flimkit/UI/icon.ico +0 -0
- flimkit/UI/icon.png +0 -0
- flimkit/UI/irf_widget.py +103 -0
- flimkit/UI/mode_controller.py +118 -0
- flimkit/UI/modes/__init__.py +0 -0
- flimkit/UI/modes/base.py +3 -0
- flimkit/UI/modes/batch_mode.py +312 -0
- flimkit/UI/modes/fov_mode.py +164 -0
- flimkit/UI/modes/irf_mode.py +80 -0
- flimkit/UI/modes/phasor_mode.py +131 -0
- flimkit/UI/modes/stitch_mode.py +254 -0
- flimkit/UI/phasor_panel.py +1087 -0
- flimkit/UI/progress_window.py +113 -0
- flimkit/UI/project_panel.py +262 -0
- flimkit/UI/results_panel.py +332 -0
- flimkit/UI/roi_tools.py +794 -0
- flimkit/UI/utils.py +217 -0
- flimkit/__init__.py +0 -0
- flimkit/_version.py +41 -0
- flimkit/cli.py +120 -0
- flimkit/configs.py +148 -0
- flimkit/dialogs.py +46 -0
- flimkit/formats/BH/__init__.py +0 -0
- flimkit/formats/BH/reader.py +296 -0
- flimkit/formats/BH/writer.py +86 -0
- flimkit/formats/ISS/__init__.py +0 -0
- flimkit/formats/ISS/fdflim.py +86 -0
- flimkit/formats/ISS/image.py +114 -0
- flimkit/formats/ISS/reader.py +223 -0
- flimkit/formats/PS/__init__.py +0 -0
- flimkit/formats/PS/reader.py +202 -0
- flimkit/formats/PTU/__init__.py +0 -0
- flimkit/formats/PTU/decode.py +27 -0
- flimkit/formats/PTU/phu.py +85 -0
- flimkit/formats/PTU/reader.py +235 -0
- flimkit/formats/PTU/series.py +258 -0
- flimkit/formats/PTU/stitch.py +1182 -0
- flimkit/formats/PTU/tools.py +94 -0
- flimkit/formats/__init__.py +2 -0
- flimkit/formats/flim_file.py +232 -0
- flimkit/formats/phasor.py +132 -0
- flimkit/formats/signal.py +170 -0
- flimkit/image/tools.py +124 -0
- flimkit/interactive.py +1857 -0
- flimkit/mpl_backend.py +22 -0
- flimkit/phasor/__init__.py +40 -0
- flimkit/phasor/filters.py +127 -0
- flimkit/phasor/fret.py +654 -0
- flimkit/phasor/interactive.py +556 -0
- flimkit/phasor/peaks.py +186 -0
- flimkit/phasor/signal.py +90 -0
- flimkit/phasor_launcher.py +314 -0
- flimkit/plugins/__init__.py +137 -0
- flimkit/plugins/bindings.py +116 -0
- flimkit/plugins/builtin/__init__.py +3 -0
- flimkit/plugins/builtin/core_tools.py +28 -0
- flimkit/plugins/loader.py +371 -0
- flimkit/plugins/registry.py +406 -0
- flimkit/project.py +197 -0
- flimkit/synth.py +145 -0
- flimkit/utils/__init__.py +0 -0
- flimkit/utils/batch_fit.py +301 -0
- flimkit/utils/config_manager.py +119 -0
- flimkit/utils/config_snapshot.py +30 -0
- flimkit/utils/crash_handler.py +183 -0
- flimkit/utils/display.py +197 -0
- flimkit/utils/enhanced_outputs.py +345 -0
- flimkit/utils/fancy.py +103 -0
- flimkit/utils/lifetime_image.py +243 -0
- flimkit/utils/misc.py +111 -0
- flimkit/utils/plotting.py +190 -0
- flimkit/utils/roi.py +370 -0
- flimkit/utils/session.py +51 -0
- flimkit/utils/update_check.py +198 -0
- flimkit/utils/xlsx_tools.py +97 -0
- flimkit/utils/xml_utils.py +219 -0
- flimkit-0.12.0.dist-info/METADATA +356 -0
- flimkit-0.12.0.dist-info/RECORD +104 -0
- flimkit-0.12.0.dist-info/WHEEL +5 -0
- flimkit-0.12.0.dist-info/entry_points.txt +2 -0
- flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
- flimkit-0.12.0.dist-info/top_level.txt +1 -0
flimkit/phasor/peaks.py
ADDED
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import matplotlib.pyplot as plt
|
|
3
|
+
from scipy.ndimage import gaussian_filter, label, maximum_filter, center_of_mass
|
|
4
|
+
|
|
5
|
+
from phasorpy.phasor import phasor_to_polar
|
|
6
|
+
from phasorpy.lifetime import phasor_to_apparent_lifetime
|
|
7
|
+
from phasorpy.plot import PhasorPlot
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def find_phasor_peaks(
|
|
11
|
+
real_cal,
|
|
12
|
+
imag_cal,
|
|
13
|
+
mean,
|
|
14
|
+
frequency,
|
|
15
|
+
*,
|
|
16
|
+
min_photons=0.01,
|
|
17
|
+
n_bins=256,
|
|
18
|
+
sigma=3.0,
|
|
19
|
+
neighbourhood=15,
|
|
20
|
+
threshold_frac=0.10,
|
|
21
|
+
):
|
|
22
|
+
rc = np.asarray(real_cal).squeeze().astype(float)
|
|
23
|
+
ic = np.asarray(imag_cal).squeeze().astype(float)
|
|
24
|
+
mn = np.asarray(mean).squeeze().astype(float)
|
|
25
|
+
valid = (mn >= min_photons) & ~np.isnan(rc)
|
|
26
|
+
|
|
27
|
+
g_vals = rc[valid]
|
|
28
|
+
s_vals = ic[valid]
|
|
29
|
+
|
|
30
|
+
# 2-D histogram
|
|
31
|
+
hist, g_edges, s_edges = np.histogram2d(
|
|
32
|
+
g_vals, s_vals, bins=n_bins,
|
|
33
|
+
range=[[0, 1], [0, 0.55]])
|
|
34
|
+
g_centers = 0.5 * (g_edges[:-1] + g_edges[1:])
|
|
35
|
+
s_centers = 0.5 * (s_edges[:-1] + s_edges[1:])
|
|
36
|
+
|
|
37
|
+
# Smooth + local-maximum detection
|
|
38
|
+
hist_smooth = gaussian_filter(hist, sigma=sigma)
|
|
39
|
+
local_max = maximum_filter(hist_smooth, size=neighbourhood)
|
|
40
|
+
threshold = threshold_frac * hist_smooth.max()
|
|
41
|
+
peak_mask = (hist_smooth == local_max) & (hist_smooth > threshold)
|
|
42
|
+
|
|
43
|
+
labelled, n_peaks = label(peak_mask)
|
|
44
|
+
|
|
45
|
+
# Weighted centroids → phasor coordinates
|
|
46
|
+
peak_coords = center_of_mass(hist_smooth, labelled,
|
|
47
|
+
range(1, n_peaks + 1))
|
|
48
|
+
peak_g = np.empty(n_peaks)
|
|
49
|
+
peak_s = np.empty(n_peaks)
|
|
50
|
+
for i, (ig, is_) in enumerate(peak_coords):
|
|
51
|
+
peak_g[i] = np.interp(ig, np.arange(n_bins), g_centers)
|
|
52
|
+
peak_s[i] = np.interp(is_, np.arange(n_bins), s_centers)
|
|
53
|
+
|
|
54
|
+
# Lifetimes & polar coordinates
|
|
55
|
+
tau_phase, tau_mod = phasor_to_apparent_lifetime(
|
|
56
|
+
peak_g, peak_s, frequency)
|
|
57
|
+
phase_vals, mod_vals = phasor_to_polar(peak_g, peak_s)
|
|
58
|
+
|
|
59
|
+
# On-semicircle test (distance from centre (0.5, 0) with r = 0.5)
|
|
60
|
+
r = np.sqrt((peak_g - 0.5) ** 2 + peak_s ** 2)
|
|
61
|
+
on_semicircle = np.abs(r - 0.5) < 0.02
|
|
62
|
+
|
|
63
|
+
return dict(
|
|
64
|
+
n_peaks=n_peaks,
|
|
65
|
+
peak_g=peak_g,
|
|
66
|
+
peak_s=peak_s,
|
|
67
|
+
tau_phase=tau_phase,
|
|
68
|
+
tau_mod=tau_mod,
|
|
69
|
+
phase=phase_vals,
|
|
70
|
+
modulation=mod_vals,
|
|
71
|
+
on_semicircle=on_semicircle,
|
|
72
|
+
hist=hist,
|
|
73
|
+
hist_smooth=hist_smooth,
|
|
74
|
+
g_centers=g_centers,
|
|
75
|
+
s_centers=s_centers,
|
|
76
|
+
)
|
|
77
|
+
|
|
78
|
+
|
|
79
|
+
def print_peaks(peaks: dict) -> None:
|
|
80
|
+
n = peaks['n_peaks']
|
|
81
|
+
print(f"Found {n} peak(s) in the phasor histogram\n")
|
|
82
|
+
print(f"{'Peak':>4} {'G':>7} {'S':>7} {'τ_φ (ns)':>9} "
|
|
83
|
+
f"{'τ_m (ns)':>9} {'phase(°)':>9} {'mod':>6}")
|
|
84
|
+
print('-' * 68)
|
|
85
|
+
for i in range(n):
|
|
86
|
+
print(f"{i+1:4d} {peaks['peak_g'][i]:7.4f} "
|
|
87
|
+
f"{peaks['peak_s'][i]:7.4f} "
|
|
88
|
+
f"{peaks['tau_phase'][i]:9.3f} "
|
|
89
|
+
f"{peaks['tau_mod'][i]:9.3f} "
|
|
90
|
+
f"{np.degrees(peaks['phase'][i]):9.2f} "
|
|
91
|
+
f"{peaks['modulation'][i]:6.4f}")
|
|
92
|
+
print()
|
|
93
|
+
for i in range(n):
|
|
94
|
+
if peaks['on_semicircle'][i]:
|
|
95
|
+
print(f"Peak {i+1}: ON semicircle → single-exponential, "
|
|
96
|
+
f"τ ≈ {peaks['tau_phase'][i]:.2f} ns")
|
|
97
|
+
else:
|
|
98
|
+
r = abs(np.sqrt((peaks['peak_g'][i] - 0.5)**2 +
|
|
99
|
+
peaks['peak_s'][i]**2) - 0.5)
|
|
100
|
+
print(f"Peak {i+1}: INSIDE semicircle (dist={r:.3f}) → "
|
|
101
|
+
f"multi-exponential mixture")
|
|
102
|
+
print(f" τ_φ = {peaks['tau_phase'][i]:.2f} ns, "
|
|
103
|
+
f"τ_m = {peaks['tau_mod'][i]:.2f} ns")
|
|
104
|
+
|
|
105
|
+
|
|
106
|
+
def plot_phasor_peaks(
|
|
107
|
+
peaks: dict,
|
|
108
|
+
real_cal: np.ndarray,
|
|
109
|
+
imag_cal: np.ndarray,
|
|
110
|
+
mean: np.ndarray,
|
|
111
|
+
frequency: float,
|
|
112
|
+
*,
|
|
113
|
+
min_photons: float = 0.01,
|
|
114
|
+
figsize: tuple[float, float] = (13, 5.5),
|
|
115
|
+
ax_phasor=None,
|
|
116
|
+
) -> plt.Figure | None:
|
|
117
|
+
n = peaks['n_peaks']
|
|
118
|
+
pg, ps = peaks['peak_g'], peaks['peak_s']
|
|
119
|
+
tp = peaks['tau_phase']
|
|
120
|
+
|
|
121
|
+
# Annotate an existing axes (e.g. the interactive phasor)
|
|
122
|
+
if ax_phasor is not None:
|
|
123
|
+
_annotate_peaks(ax_phasor, n, pg, ps, tp)
|
|
124
|
+
ax_phasor.figure.canvas.draw_idle()
|
|
125
|
+
return None
|
|
126
|
+
|
|
127
|
+
# Full standalone figure
|
|
128
|
+
rc = np.asarray(real_cal).squeeze().astype(float)
|
|
129
|
+
ic = np.asarray(imag_cal).squeeze().astype(float)
|
|
130
|
+
mn = np.asarray(mean).squeeze().astype(float)
|
|
131
|
+
valid = (mn >= min_photons) & ~np.isnan(rc)
|
|
132
|
+
g_vals, s_vals = rc[valid], ic[valid]
|
|
133
|
+
|
|
134
|
+
fig, axes = plt.subplots(1, 2, figsize=figsize)
|
|
135
|
+
|
|
136
|
+
# Left - PhasorPlot hist2d with markers
|
|
137
|
+
ax = axes[0]
|
|
138
|
+
pp = PhasorPlot(frequency=frequency, ax=ax,
|
|
139
|
+
title='Phasor histogram - peak detection')
|
|
140
|
+
pp.hist2d(g_vals, s_vals)
|
|
141
|
+
_annotate_peaks(ax, n, pg, ps, tp)
|
|
142
|
+
|
|
143
|
+
# Right - smoothed contour + peaks
|
|
144
|
+
ax2 = axes[1]
|
|
145
|
+
GG, SS = np.meshgrid(peaks['g_centers'], peaks['s_centers'])
|
|
146
|
+
ax2.contourf(GG, SS, peaks['hist_smooth'].T, levels=30, cmap='Blues')
|
|
147
|
+
ax2.contour(GG, SS, peaks['hist_smooth'].T, levels=10,
|
|
148
|
+
colors='grey', linewidths=0.5)
|
|
149
|
+
for i in range(n):
|
|
150
|
+
ax2.plot(pg[i], ps[i], 'r*', ms=18,
|
|
151
|
+
markeredgecolor='k', markeredgewidth=0.8, zorder=20)
|
|
152
|
+
ax2.annotate(
|
|
153
|
+
f'Peak {i+1}\nτ_φ={tp[i]:.2f} ns\nτ_m={peaks['tau_mod'][i]:.2f} ns',
|
|
154
|
+
xy=(pg[i], ps[i]), xytext=(12, 12),
|
|
155
|
+
textcoords='offset points', fontsize=8, color='red',
|
|
156
|
+
fontweight='bold',
|
|
157
|
+
bbox=dict(boxstyle='round,pad=0.3', fc='white', alpha=0.8),
|
|
158
|
+
arrowprops=dict(arrowstyle='->', color='red', lw=1.2))
|
|
159
|
+
theta = np.linspace(0, np.pi, 200)
|
|
160
|
+
ax2.plot(0.5 + 0.5 * np.cos(theta), 0.5 * np.sin(theta),
|
|
161
|
+
'k-', lw=1, alpha=0.5)
|
|
162
|
+
ax2.set_xlabel('G (real)')
|
|
163
|
+
ax2.set_ylabel('S (imag)')
|
|
164
|
+
ax2.set_title('Smoothed contour + peaks')
|
|
165
|
+
ax2.set_xlim(0, 1.05)
|
|
166
|
+
ax2.set_ylim(-0.02, 0.55)
|
|
167
|
+
ax2.set_aspect('equal')
|
|
168
|
+
|
|
169
|
+
fig.tight_layout()
|
|
170
|
+
return fig
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _annotate_peaks(ax, n, pg, ps, tp):
|
|
174
|
+
artists = []
|
|
175
|
+
for i in range(n):
|
|
176
|
+
(star,) = ax.plot(pg[i], ps[i], 'r*', ms=18,
|
|
177
|
+
markeredgecolor='k', markeredgewidth=0.8,
|
|
178
|
+
zorder=20)
|
|
179
|
+
ann = ax.annotate(
|
|
180
|
+
f'τ_φ={tp[i]:.2f} ns',
|
|
181
|
+
xy=(pg[i], ps[i]), xytext=(10, 10),
|
|
182
|
+
textcoords='offset points', fontsize=9, color='red',
|
|
183
|
+
fontweight='bold',
|
|
184
|
+
arrowprops=dict(arrowstyle='->', color='red', lw=1.2))
|
|
185
|
+
artists.extend([star, ann])
|
|
186
|
+
return artists
|
flimkit/phasor/signal.py
ADDED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import numpy as np
|
|
2
|
+
import pandas as pd
|
|
3
|
+
from phasorpy.plot import plot_phasor
|
|
4
|
+
from ..formats.PTU.tools import signal_from_PTUFile
|
|
5
|
+
from phasorpy.phasor import phasor_from_signal
|
|
6
|
+
from phasorpy.phasor import phasor_to_polar, phasor_transform
|
|
7
|
+
|
|
8
|
+
def return_phasor_from_PTUFile(ptu_file):
|
|
9
|
+
signal = signal_from_PTUFile(ptu_file, dtype=np.uint32, binning=4)
|
|
10
|
+
mean, real, imag = phasor_from_signal(signal, axis='H')
|
|
11
|
+
return mean, real, imag
|
|
12
|
+
|
|
13
|
+
def process_ifli(ifli_path, phasor_filter=None, filter_kwargs=None, channel=None):
|
|
14
|
+
from ..formats import FLIMFile
|
|
15
|
+
print(f"Loading frequency-domain file: {ifli_path}")
|
|
16
|
+
iss = FLIMFile(str(ifli_path), verbose=True, channel=channel)
|
|
17
|
+
mean, real, imag, frequency = iss.phasor(channel=channel)
|
|
18
|
+
print(f" FD-FLIM phasor coordinates (frequency = {frequency:.2f} MHz)")
|
|
19
|
+
real_cal, imag_cal = real, imag
|
|
20
|
+
if phasor_filter:
|
|
21
|
+
from .filters import phasor_filter as _filter_fn
|
|
22
|
+
real_cal, imag_cal = _filter_fn(
|
|
23
|
+
np.asarray(real_cal, dtype=float),
|
|
24
|
+
np.asarray(imag_cal, dtype=float),
|
|
25
|
+
phasor_filter,
|
|
26
|
+
mean=np.asarray(mean, dtype=float),
|
|
27
|
+
**(filter_kwargs or {}),
|
|
28
|
+
)
|
|
29
|
+
return dict(
|
|
30
|
+
real_cal=np.asarray(real_cal),
|
|
31
|
+
imag_cal=np.asarray(imag_cal),
|
|
32
|
+
mean=np.asarray(mean),
|
|
33
|
+
frequency=float(frequency),
|
|
34
|
+
channel=channel,
|
|
35
|
+
display_image=np.asarray(mean, dtype=float),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
def get_phasor_irf(irf_xlsx):
|
|
39
|
+
df = pd.read_excel(irf_xlsx, sheet_name='Fit', header=None)
|
|
40
|
+
irf_time_ns = pd.to_numeric(df.iloc[2:, 2], errors='coerce').dropna().values
|
|
41
|
+
irf_counts = pd.to_numeric(df.iloc[2:, 3], errors='coerce').dropna().values
|
|
42
|
+
return irf_time_ns, irf_counts
|
|
43
|
+
|
|
44
|
+
def calibrate_signal_with_irf(signal, real, imag, irf_time_ns, irf_counts, frequency):
|
|
45
|
+
signal_time_ns = signal.coords['H'].values
|
|
46
|
+
irf_on_signal = np.interp(signal_time_ns, irf_time_ns, irf_counts, left=0, right=0)
|
|
47
|
+
|
|
48
|
+
mean_irf, real_irf, imag_irf = phasor_from_signal(
|
|
49
|
+
irf_on_signal[np.newaxis, :], axis=-1
|
|
50
|
+
)
|
|
51
|
+
phase_irf, mod_irf = phasor_to_polar(real_irf.ravel(), imag_irf.ravel())
|
|
52
|
+
real_cal, imag_cal = phasor_transform(real, imag, -phase_irf[0], 1.0 / mod_irf[0],)
|
|
53
|
+
return real_cal, imag_cal
|
|
54
|
+
|
|
55
|
+
def plot_calibrated_phasor(real, imag, signal):
|
|
56
|
+
frequency = signal.attrs['frequency']
|
|
57
|
+
plot_phasor(
|
|
58
|
+
real,
|
|
59
|
+
imag,
|
|
60
|
+
frequency=frequency,
|
|
61
|
+
title='Calibrated, filtered phasor coordinates',
|
|
62
|
+
)
|
|
63
|
+
|
|
64
|
+
def calibrate_signal_with_machine_irf(signal, real, imag, machine_irf_npy: str,
|
|
65
|
+
frequency: float):
|
|
66
|
+
import json
|
|
67
|
+
from pathlib import Path
|
|
68
|
+
|
|
69
|
+
npy_path = Path(machine_irf_npy)
|
|
70
|
+
meta_path = npy_path.with_name(npy_path.stem + '_meta.json')
|
|
71
|
+
|
|
72
|
+
irf_arr = np.load(str(npy_path))
|
|
73
|
+
|
|
74
|
+
if meta_path.exists():
|
|
75
|
+
with open(meta_path) as f:
|
|
76
|
+
meta = json.load(f)
|
|
77
|
+
tcspc_res_ns = meta.get('tcspc_res_ns', None)
|
|
78
|
+
else:
|
|
79
|
+
tcspc_res_ns = None
|
|
80
|
+
|
|
81
|
+
signal_time_ns = signal.coords['H'].values
|
|
82
|
+
n_irf = len(irf_arr)
|
|
83
|
+
|
|
84
|
+
if tcspc_res_ns is not None:
|
|
85
|
+
irf_time_ns = np.arange(n_irf) * tcspc_res_ns
|
|
86
|
+
else:
|
|
87
|
+
irf_time_ns = np.linspace(signal_time_ns[0], signal_time_ns[-1], n_irf)
|
|
88
|
+
|
|
89
|
+
return calibrate_signal_with_irf(signal, real, imag,
|
|
90
|
+
irf_time_ns, irf_arr, frequency)
|
|
@@ -0,0 +1,314 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import sys
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
def _yes_no(question):
|
|
8
|
+
import inquirer
|
|
9
|
+
ans = inquirer.prompt([inquirer.List(
|
|
10
|
+
'yesno', message=question, choices=['Yes', 'No'])])
|
|
11
|
+
return ans['yesno'] == 'Yes'
|
|
12
|
+
|
|
13
|
+
def _ask_path(message, *, optional=False):
|
|
14
|
+
import inquirer
|
|
15
|
+
hint = ' (leave blank to skip)' if optional else ''
|
|
16
|
+
ans = inquirer.prompt([
|
|
17
|
+
inquirer.Path('path',
|
|
18
|
+
message=f'{message}{hint}',
|
|
19
|
+
path_type=inquirer.Path.FILE,
|
|
20
|
+
exists=not optional)])
|
|
21
|
+
val = (ans or {}).get('path', '').strip()
|
|
22
|
+
if not val:
|
|
23
|
+
return None
|
|
24
|
+
return val
|
|
25
|
+
|
|
26
|
+
def _pick_save_file(title: str, default_name: str) -> str | None:
|
|
27
|
+
from .dialogs import ask_save_path
|
|
28
|
+
return ask_save_path(title, default_name, defaultextension='.npz',
|
|
29
|
+
filetypes=[('NumPy archive', '*.npz'), ('All files', '*')])
|
|
30
|
+
|
|
31
|
+
def save_session(path, *,
|
|
32
|
+
real_cal,
|
|
33
|
+
imag_cal,
|
|
34
|
+
mean,
|
|
35
|
+
frequency,
|
|
36
|
+
cursors,
|
|
37
|
+
params,
|
|
38
|
+
ptu_file=None,
|
|
39
|
+
irf_file=None,
|
|
40
|
+
display_image=None):
|
|
41
|
+
n = len(cursors)
|
|
42
|
+
cursor_g = np.array([c['center_g'] for c in cursors], dtype=float) if n else np.array([], dtype=float)
|
|
43
|
+
cursor_s = np.array([c['center_s'] for c in cursors], dtype=float) if n else np.array([], dtype=float)
|
|
44
|
+
cursor_colors = np.array([c['color'] for c in cursors], dtype='U10') if n else np.array([], dtype='U10')
|
|
45
|
+
save_kw = dict(
|
|
46
|
+
real_cal=real_cal,
|
|
47
|
+
imag_cal=imag_cal,
|
|
48
|
+
mean=mean,
|
|
49
|
+
frequency=np.float64(frequency),
|
|
50
|
+
cursor_g=cursor_g,
|
|
51
|
+
cursor_s=cursor_s,
|
|
52
|
+
cursor_colors=cursor_colors,
|
|
53
|
+
param_radius=np.float64(params.get('radius', 0.05)),
|
|
54
|
+
param_radius_minor=np.float64(params.get('radius_minor', 0.03)),
|
|
55
|
+
param_angle_mode=np.array(params.get('angle_mode', 'semicircle')),
|
|
56
|
+
ptu_file=np.array(ptu_file or ''),
|
|
57
|
+
irf_file=np.array(irf_file or ''),
|
|
58
|
+
)
|
|
59
|
+
if display_image is not None:
|
|
60
|
+
save_kw['display_image'] = np.asarray(display_image)
|
|
61
|
+
np.savez_compressed(path, **save_kw)
|
|
62
|
+
print(f'Session saved → {path} ({n} cursor(s))')
|
|
63
|
+
|
|
64
|
+
def load_session(path):
|
|
65
|
+
d = np.load(path, allow_pickle=False)
|
|
66
|
+
cursors = []
|
|
67
|
+
g = d['cursor_g']
|
|
68
|
+
s = d['cursor_s']
|
|
69
|
+
colors = d['cursor_colors']
|
|
70
|
+
for i in range(len(g)):
|
|
71
|
+
cursors.append(dict(
|
|
72
|
+
center_g=float(g[i]),
|
|
73
|
+
center_s=float(s[i]),
|
|
74
|
+
color=str(colors[i]),
|
|
75
|
+
))
|
|
76
|
+
params = dict(
|
|
77
|
+
radius=float(d['param_radius']),
|
|
78
|
+
radius_minor=float(d['param_radius_minor']),
|
|
79
|
+
angle_mode=str(d['param_angle_mode']),
|
|
80
|
+
)
|
|
81
|
+
return dict(
|
|
82
|
+
real_cal=d['real_cal'],
|
|
83
|
+
imag_cal=d['imag_cal'],
|
|
84
|
+
mean=d['mean'],
|
|
85
|
+
frequency=float(d['frequency']),
|
|
86
|
+
cursors=cursors,
|
|
87
|
+
params=params,
|
|
88
|
+
ptu_file=str(d['ptu_file']) or None,
|
|
89
|
+
irf_file=str(d['irf_file']) or None,
|
|
90
|
+
display_image=d['display_image'] if 'display_image' in d else None,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
def get_ptu_active_channels(ptu_path):
|
|
94
|
+
from flimkit.formats import FLIMFile
|
|
95
|
+
ptu = FLIMFile(str(ptu_path), verbose=False)
|
|
96
|
+
active = getattr(ptu, '_active', None)
|
|
97
|
+
if not active:
|
|
98
|
+
active = list(range(getattr(ptu, 'n_channels', 0) or 0))
|
|
99
|
+
return sorted(int(channel) for channel in active)
|
|
100
|
+
|
|
101
|
+
def _prompt_ptu_channel(active_channels):
|
|
102
|
+
import inquirer
|
|
103
|
+
answer = inquirer.prompt([inquirer.List(
|
|
104
|
+
'channel',
|
|
105
|
+
message=(
|
|
106
|
+
f'Multiple channels detected: {active_channels}. '
|
|
107
|
+
'Which one should be used for phasor analysis?'
|
|
108
|
+
),
|
|
109
|
+
choices=[f'Channel {channel}' for channel in active_channels],
|
|
110
|
+
)])
|
|
111
|
+
if not answer or 'channel' not in answer:
|
|
112
|
+
raise ValueError('No channel selected for phasor analysis')
|
|
113
|
+
return int(str(answer['channel']).split()[-1])
|
|
114
|
+
|
|
115
|
+
def resolve_ptu_channel(
|
|
116
|
+
ptu_path,
|
|
117
|
+
channel=None,
|
|
118
|
+
*,
|
|
119
|
+
prompt_fn=None,
|
|
120
|
+
):
|
|
121
|
+
active_channels = get_ptu_active_channels(ptu_path)
|
|
122
|
+
if not active_channels:
|
|
123
|
+
raise ValueError('No photon channels found in PTU file')
|
|
124
|
+
if channel is not None:
|
|
125
|
+
selected_channel = int(channel)
|
|
126
|
+
if selected_channel not in active_channels:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
f'Channel {selected_channel} is not present in PTU file. '
|
|
129
|
+
f'Available channels: {active_channels}'
|
|
130
|
+
)
|
|
131
|
+
return selected_channel
|
|
132
|
+
if len(active_channels) == 1:
|
|
133
|
+
selected_channel = active_channels[0]
|
|
134
|
+
print(f'Auto-selected channel {selected_channel} (only channel available)')
|
|
135
|
+
return selected_channel
|
|
136
|
+
if prompt_fn is None:
|
|
137
|
+
prompt_fn = _prompt_ptu_channel
|
|
138
|
+
selected_channel = int(prompt_fn(active_channels))
|
|
139
|
+
if selected_channel not in active_channels:
|
|
140
|
+
raise ValueError(
|
|
141
|
+
f'Channel {selected_channel} is not present in PTU file. '
|
|
142
|
+
f'Available channels: {active_channels}'
|
|
143
|
+
)
|
|
144
|
+
return selected_channel
|
|
145
|
+
|
|
146
|
+
def _process_ptu(ptu_path, irf_path=None, channel=None, phasor_filter=None,
|
|
147
|
+
filter_kwargs=None):
|
|
148
|
+
from phasorpy.phasor import phasor_from_signal
|
|
149
|
+
from .formats.PTU.tools import signal_from_PTUFile
|
|
150
|
+
from flimkit.formats import FLIMFile
|
|
151
|
+
from .phasor.signal import get_phasor_irf, calibrate_signal_with_irf
|
|
152
|
+
print(f'Loading PTU file: {ptu_path}')
|
|
153
|
+
channel = resolve_ptu_channel(ptu_path, channel=channel)
|
|
154
|
+
print(f'Using channel: {channel}')
|
|
155
|
+
signal = signal_from_PTUFile(ptu_path, dtype=np.uint32, binning=4, channel=channel)
|
|
156
|
+
frequency = float(signal.attrs['frequency'])
|
|
157
|
+
print(f'Computing phasors (frequency = {frequency:.2f} MHz) ...')
|
|
158
|
+
mean, real, imag = phasor_from_signal(signal, axis='H')
|
|
159
|
+
ptu = FLIMFile(str(ptu_path), verbose=False)
|
|
160
|
+
if getattr(ptu, 'is_image', True):
|
|
161
|
+
display_image = ptu.raw_pixel_stack(channel=channel, binning=4).sum(axis=-1)
|
|
162
|
+
else:
|
|
163
|
+
print(' Point measurement: no image to reconstruct.')
|
|
164
|
+
display_image = None
|
|
165
|
+
if irf_path:
|
|
166
|
+
from .phasor.signal import calibrate_signal_with_machine_irf
|
|
167
|
+
irf_path_p = Path(irf_path)
|
|
168
|
+
if irf_path_p.suffix.lower() == '.npy':
|
|
169
|
+
print(f'Calibrating with machine IRF (.npy): {irf_path}')
|
|
170
|
+
real_cal, imag_cal = calibrate_signal_with_machine_irf(
|
|
171
|
+
signal, real, imag, irf_path, frequency)
|
|
172
|
+
else:
|
|
173
|
+
print(f'Calibrating with IRF (.xlsx): {irf_path}')
|
|
174
|
+
irf_time_ns, irf_counts = get_phasor_irf(irf_path)
|
|
175
|
+
real_cal, imag_cal = calibrate_signal_with_irf(
|
|
176
|
+
signal, real, imag, irf_time_ns, irf_counts, frequency)
|
|
177
|
+
else:
|
|
178
|
+
print(' No IRF - using uncalibrated phasor coordinates.')
|
|
179
|
+
real_cal, imag_cal = real, imag
|
|
180
|
+
if phasor_filter:
|
|
181
|
+
from .phasor.filters import phasor_filter as _filter_fn
|
|
182
|
+
print(f'Applying phasor filter: {phasor_filter} ...')
|
|
183
|
+
real_cal, imag_cal = _filter_fn(
|
|
184
|
+
np.asarray(real_cal, dtype=float),
|
|
185
|
+
np.asarray(imag_cal, dtype=float),
|
|
186
|
+
phasor_filter,
|
|
187
|
+
mean=np.asarray(mean, dtype=float),
|
|
188
|
+
**(filter_kwargs or {}),
|
|
189
|
+
)
|
|
190
|
+
return dict(
|
|
191
|
+
real_cal=np.asarray(real_cal),
|
|
192
|
+
imag_cal=np.asarray(imag_cal),
|
|
193
|
+
mean=np.asarray(mean),
|
|
194
|
+
frequency=frequency,
|
|
195
|
+
channel=channel,
|
|
196
|
+
display_image=(np.asarray(display_image, dtype=float)
|
|
197
|
+
if display_image is not None else None),
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
def launch_phasor(ptu_path=None,
|
|
201
|
+
irf_path=None,
|
|
202
|
+
machine_irf_path=None,
|
|
203
|
+
session_path=None,
|
|
204
|
+
*,
|
|
205
|
+
channel=None,
|
|
206
|
+
phasor_filter=None,
|
|
207
|
+
filter_kwargs=None,
|
|
208
|
+
min_photons=0.01,
|
|
209
|
+
max_cursors=6,
|
|
210
|
+
figsize=(8, 5)):
|
|
211
|
+
from .phasor.interactive import phasor_cursor_tool
|
|
212
|
+
from flimkit.formats import file_modality
|
|
213
|
+
initial_cursors = None
|
|
214
|
+
initial_params = None
|
|
215
|
+
src_ptu = ptu_path
|
|
216
|
+
src_irf = irf_path
|
|
217
|
+
if session_path is None and ptu_path is None:
|
|
218
|
+
import inquirer
|
|
219
|
+
mode = inquirer.prompt([inquirer.List(
|
|
220
|
+
'mode',
|
|
221
|
+
message='What would you like to do?',
|
|
222
|
+
choices=[
|
|
223
|
+
'Analyse a new PTU file',
|
|
224
|
+
'Resume a saved session (.npz)',
|
|
225
|
+
],
|
|
226
|
+
)])['mode']
|
|
227
|
+
if mode.startswith('Resume'):
|
|
228
|
+
session_path = _ask_path('Path to saved .npz session')
|
|
229
|
+
if session_path is None:
|
|
230
|
+
print('No file specified - aborting.')
|
|
231
|
+
return {}
|
|
232
|
+
else:
|
|
233
|
+
ptu_path = _ask_path('Path to PTU file')
|
|
234
|
+
if ptu_path is None:
|
|
235
|
+
print('No file specified - aborting.')
|
|
236
|
+
return {}
|
|
237
|
+
if session_path:
|
|
238
|
+
print(f'Loading session: {session_path}')
|
|
239
|
+
sess = load_session(session_path)
|
|
240
|
+
data = dict(
|
|
241
|
+
real_cal=sess['real_cal'],
|
|
242
|
+
imag_cal=sess['imag_cal'],
|
|
243
|
+
mean=sess['mean'],
|
|
244
|
+
frequency=sess['frequency'],
|
|
245
|
+
display_image=sess.get('display_image'),
|
|
246
|
+
)
|
|
247
|
+
initial_cursors = sess['cursors'] or None
|
|
248
|
+
initial_params = sess['params']
|
|
249
|
+
src_ptu = sess.get('ptu_file')
|
|
250
|
+
src_irf = sess.get('irf_file')
|
|
251
|
+
print(f' frequency = {data['frequency']:.2f} MHz, '
|
|
252
|
+
f'{len(sess['cursors'])} cursor(s) restored')
|
|
253
|
+
elif ptu_path and file_modality(ptu_path) == 'frequency':
|
|
254
|
+
from .phasor.signal import process_ifli
|
|
255
|
+
data = process_ifli(ptu_path, phasor_filter=phasor_filter,
|
|
256
|
+
filter_kwargs=filter_kwargs, channel=channel)
|
|
257
|
+
else:
|
|
258
|
+
if irf_path is None and machine_irf_path is None:
|
|
259
|
+
choices = [
|
|
260
|
+
'XLSX IRF (analytical model)',
|
|
261
|
+
'Machine IRF (.npy pre-built)',
|
|
262
|
+
'No IRF (uncalibrated)',
|
|
263
|
+
]
|
|
264
|
+
irf_choice = inquirer.prompt([inquirer.List(
|
|
265
|
+
'irf', message='IRF calibration source?', choices=choices
|
|
266
|
+
)])['irf']
|
|
267
|
+
if irf_choice.startswith('XLSX'):
|
|
268
|
+
irf_path = _ask_path('Path to IRF Excel file (.xlsx)')
|
|
269
|
+
elif irf_choice.startswith('Machine'):
|
|
270
|
+
machine_irf_path = _ask_path('Path to machine IRF (.npy)')
|
|
271
|
+
effective_irf = irf_path or machine_irf_path
|
|
272
|
+
src_irf = effective_irf
|
|
273
|
+
effective_irf = irf_path or machine_irf_path
|
|
274
|
+
data = _process_ptu(ptu_path, effective_irf, channel=channel,
|
|
275
|
+
phasor_filter=phasor_filter,
|
|
276
|
+
filter_kwargs=filter_kwargs)
|
|
277
|
+
_data = data
|
|
278
|
+
|
|
279
|
+
def _save_callback(state, params):
|
|
280
|
+
stem = Path(src_ptu).stem if src_ptu else 'phasor_session'
|
|
281
|
+
default_name = f'{stem}_session.npz'
|
|
282
|
+
out = _pick_save_file('Save phasor session', default_name)
|
|
283
|
+
if out:
|
|
284
|
+
save_session(
|
|
285
|
+
out,
|
|
286
|
+
real_cal=_data['real_cal'],
|
|
287
|
+
imag_cal=_data['imag_cal'],
|
|
288
|
+
mean=_data['mean'],
|
|
289
|
+
frequency=_data['frequency'],
|
|
290
|
+
cursors=state['cursors'],
|
|
291
|
+
params=params,
|
|
292
|
+
ptu_file=src_ptu,
|
|
293
|
+
irf_file=src_irf,
|
|
294
|
+
display_image=_data.get('display_image'),
|
|
295
|
+
)
|
|
296
|
+
state = phasor_cursor_tool(
|
|
297
|
+
data['real_cal'],
|
|
298
|
+
data['imag_cal'],
|
|
299
|
+
data['mean'],
|
|
300
|
+
data['frequency'], display_image=data.get('display_image'), min_photons=min_photons,
|
|
301
|
+
max_cursors=max_cursors,
|
|
302
|
+
figsize=figsize,
|
|
303
|
+
initial_cursors=initial_cursors,
|
|
304
|
+
initial_params=initial_params,
|
|
305
|
+
on_save=_save_callback,
|
|
306
|
+
)
|
|
307
|
+
return state
|
|
308
|
+
|
|
309
|
+
def phasor_inquire():
|
|
310
|
+
print('\n Interactive Phasor Analysis')
|
|
311
|
+
return launch_phasor()
|
|
312
|
+
|
|
313
|
+
if __name__ == '__main__':
|
|
314
|
+
phasor_inquire()
|