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.
Files changed (104) hide show
  1. flimkit/FLIM/__init__.py +0 -0
  2. flimkit/FLIM/assemble.py +254 -0
  3. flimkit/FLIM/batch.py +681 -0
  4. flimkit/FLIM/bg_tools.py +51 -0
  5. flimkit/FLIM/fit_tools.py +244 -0
  6. flimkit/FLIM/fitters.py +1471 -0
  7. flimkit/FLIM/irf_tools.py +617 -0
  8. flimkit/FLIM/models.py +391 -0
  9. flimkit/GPU/__init__.py +85 -0
  10. flimkit/GPU/_base.py +391 -0
  11. flimkit/GPU/cuda.py +10 -0
  12. flimkit/GPU/mlx_backend.py +381 -0
  13. flimkit/GPU/mps.py +10 -0
  14. flimkit/GPU/rocm.py +10 -0
  15. flimkit/GPU/torch_backend.py +385 -0
  16. flimkit/UI/app_state.py +10 -0
  17. flimkit/UI/controller.py +139 -0
  18. flimkit/UI/expert_settings.py +248 -0
  19. flimkit/UI/fit_help.py +206 -0
  20. flimkit/UI/fov_preview.py +1085 -0
  21. flimkit/UI/gui.py +3919 -0
  22. flimkit/UI/icon.icns +0 -0
  23. flimkit/UI/icon.ico +0 -0
  24. flimkit/UI/icon.png +0 -0
  25. flimkit/UI/irf_widget.py +103 -0
  26. flimkit/UI/mode_controller.py +118 -0
  27. flimkit/UI/modes/__init__.py +0 -0
  28. flimkit/UI/modes/base.py +3 -0
  29. flimkit/UI/modes/batch_mode.py +312 -0
  30. flimkit/UI/modes/fov_mode.py +164 -0
  31. flimkit/UI/modes/irf_mode.py +80 -0
  32. flimkit/UI/modes/phasor_mode.py +131 -0
  33. flimkit/UI/modes/stitch_mode.py +254 -0
  34. flimkit/UI/phasor_panel.py +1087 -0
  35. flimkit/UI/progress_window.py +113 -0
  36. flimkit/UI/project_panel.py +262 -0
  37. flimkit/UI/results_panel.py +332 -0
  38. flimkit/UI/roi_tools.py +794 -0
  39. flimkit/UI/utils.py +217 -0
  40. flimkit/__init__.py +0 -0
  41. flimkit/_version.py +41 -0
  42. flimkit/cli.py +120 -0
  43. flimkit/configs.py +148 -0
  44. flimkit/dialogs.py +46 -0
  45. flimkit/formats/BH/__init__.py +0 -0
  46. flimkit/formats/BH/reader.py +296 -0
  47. flimkit/formats/BH/writer.py +86 -0
  48. flimkit/formats/ISS/__init__.py +0 -0
  49. flimkit/formats/ISS/fdflim.py +86 -0
  50. flimkit/formats/ISS/image.py +114 -0
  51. flimkit/formats/ISS/reader.py +223 -0
  52. flimkit/formats/PS/__init__.py +0 -0
  53. flimkit/formats/PS/reader.py +202 -0
  54. flimkit/formats/PTU/__init__.py +0 -0
  55. flimkit/formats/PTU/decode.py +27 -0
  56. flimkit/formats/PTU/phu.py +85 -0
  57. flimkit/formats/PTU/reader.py +235 -0
  58. flimkit/formats/PTU/series.py +258 -0
  59. flimkit/formats/PTU/stitch.py +1182 -0
  60. flimkit/formats/PTU/tools.py +94 -0
  61. flimkit/formats/__init__.py +2 -0
  62. flimkit/formats/flim_file.py +232 -0
  63. flimkit/formats/phasor.py +132 -0
  64. flimkit/formats/signal.py +170 -0
  65. flimkit/image/tools.py +124 -0
  66. flimkit/interactive.py +1857 -0
  67. flimkit/mpl_backend.py +22 -0
  68. flimkit/phasor/__init__.py +40 -0
  69. flimkit/phasor/filters.py +127 -0
  70. flimkit/phasor/fret.py +654 -0
  71. flimkit/phasor/interactive.py +556 -0
  72. flimkit/phasor/peaks.py +186 -0
  73. flimkit/phasor/signal.py +90 -0
  74. flimkit/phasor_launcher.py +314 -0
  75. flimkit/plugins/__init__.py +137 -0
  76. flimkit/plugins/bindings.py +116 -0
  77. flimkit/plugins/builtin/__init__.py +3 -0
  78. flimkit/plugins/builtin/core_tools.py +28 -0
  79. flimkit/plugins/loader.py +371 -0
  80. flimkit/plugins/registry.py +406 -0
  81. flimkit/project.py +197 -0
  82. flimkit/synth.py +145 -0
  83. flimkit/utils/__init__.py +0 -0
  84. flimkit/utils/batch_fit.py +301 -0
  85. flimkit/utils/config_manager.py +119 -0
  86. flimkit/utils/config_snapshot.py +30 -0
  87. flimkit/utils/crash_handler.py +183 -0
  88. flimkit/utils/display.py +197 -0
  89. flimkit/utils/enhanced_outputs.py +345 -0
  90. flimkit/utils/fancy.py +103 -0
  91. flimkit/utils/lifetime_image.py +243 -0
  92. flimkit/utils/misc.py +111 -0
  93. flimkit/utils/plotting.py +190 -0
  94. flimkit/utils/roi.py +370 -0
  95. flimkit/utils/session.py +51 -0
  96. flimkit/utils/update_check.py +198 -0
  97. flimkit/utils/xlsx_tools.py +97 -0
  98. flimkit/utils/xml_utils.py +219 -0
  99. flimkit-0.12.0.dist-info/METADATA +356 -0
  100. flimkit-0.12.0.dist-info/RECORD +104 -0
  101. flimkit-0.12.0.dist-info/WHEEL +5 -0
  102. flimkit-0.12.0.dist-info/entry_points.txt +2 -0
  103. flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
  104. flimkit-0.12.0.dist-info/top_level.txt +1 -0
@@ -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
@@ -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()