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
flimkit/utils/misc.py ADDED
@@ -0,0 +1,111 @@
1
+ import os
2
+ import logging
3
+
4
+ def setup_loggers(log_dir: str = '.', log_prefix: str = 'run'):
5
+ os.makedirs(log_dir, exist_ok=True)
6
+ loggers = {}
7
+
8
+ # Main run logger
9
+ run_logger = logging.getLogger('run')
10
+ run_logger.setLevel(logging.INFO)
11
+ run_fh = logging.FileHandler(os.path.join(log_dir, f'{log_prefix}.log'))
12
+ run_fh.setLevel(logging.INFO)
13
+ run_formatter = logging.Formatter('%(asctime)s - %(levelname)s - %(message)s')
14
+ run_fh.setFormatter(run_formatter)
15
+ run_logger.handlers = [run_fh]
16
+ loggers['run'] = run_logger
17
+
18
+ # Error logger
19
+ error_logger = logging.getLogger('error')
20
+ error_logger.setLevel(logging.ERROR)
21
+ error_fh = logging.FileHandler(os.path.join(log_dir, 'error.log'))
22
+ error_fh.setLevel(logging.ERROR)
23
+ error_fh.setFormatter(run_formatter)
24
+ error_logger.handlers = [error_fh]
25
+ loggers['error'] = error_logger
26
+
27
+ # Warning logger
28
+ warning_logger = logging.getLogger('warning')
29
+ warning_logger.setLevel(logging.WARNING)
30
+ warning_fh = logging.FileHandler(os.path.join(log_dir, 'warning.log'))
31
+ warning_fh.setLevel(logging.WARNING)
32
+ warning_fh.setFormatter(run_formatter)
33
+ warning_logger.handlers = [warning_fh]
34
+ loggers['warning'] = warning_logger
35
+
36
+ return loggers
37
+
38
+ def print_summary(summary: dict, strategy: str, n_exp: int):
39
+ s = summary
40
+ tcspc_res = s['tcspc_res']
41
+ is_dist = 'tau_centers_ns' in s
42
+ is_tail = s.get('fit_model') == 'tail'
43
+
44
+ print(f"\n{'─'*60}")
45
+ if is_tail:
46
+ print(f" Fit: {n_exp}-exp tail (no reconvolution)")
47
+ print(f"{'─'*60}")
48
+ for i, (tau, amp, frac, inten, ifrac) in enumerate(
49
+ zip(s['taus_ns'], s['amps'], s['fractions'],
50
+ s['intensities'], s['intensity_fractions'])):
51
+ print(f" τ{i+1} = {tau:8.4f} ns A{i+1} = {amp:.3e} "
52
+ f"f{i+1} = {frac:.4f} I{i+1} = {inten:.3e} ({ifrac:.4f})")
53
+ print(f" A_sum = {s['a_sum']:.3e} I_sum = {s['i_sum']:.3e} cts")
54
+ elif is_dist:
55
+ width_label = 'σ' if s['dist_type'] == 'gaussian' else 'Γ'
56
+ print(f" Fit: {s['n_components']}-component {s['dist_type']} dist | IRF: {strategy}")
57
+ print(f"{'─'*60}")
58
+ for i, (tau_c, w, fwhm, amp, frac) in enumerate(
59
+ zip(s['tau_centers_ns'], s['widths_ns'], s['fwhms_ns'],
60
+ s['amps'], s['fractions'])):
61
+ print(f" τ_c{i+1} = {tau_c:7.4f} ns {width_label}{i+1} = {w:.4f} ns "
62
+ f"FWHM = {fwhm:.4f} ns f{i+1} = {frac:.4f}")
63
+ else:
64
+ print(f" Fit: {n_exp}-exp | IRF: {strategy}")
65
+ print(f"{'─'*60}")
66
+ for i, (tau, amp, frac) in enumerate(
67
+ zip(s['taus_ns'], s['amps'], s['fractions'])):
68
+ print(f" τ{i+1} = {tau:8.4f} ns α{i+1} = {amp:.3e} f{i+1} = {frac:.4f}")
69
+
70
+ print(f" τ_mean (amplitude-weighted) = {s['tau_mean_amp_ns']:.4f} ns")
71
+ print(f" τ_mean (intensity-weighted) = {s['tau_mean_int_ns']:.4f} ns")
72
+ print(f" bg (fitted, Tail Offset) = {s['bg_fit']:.2f} cts/bin")
73
+ if is_tail:
74
+ print(f" t0 (lifetime offset) = {s['t0_ns']:.4f} ns")
75
+ print(f" Fit window = {s['fit_window_ns'][0]:.2f}-"
76
+ f"{s['fit_window_ns'][1]:.2f} ns")
77
+ else:
78
+ print(f" IRF shift = {s['irf_shift_bins']:.3f} bins "
79
+ f"({s['irf_shift_bins'] * tcspc_res * 1e12:.1f} ps)")
80
+ print(f" IRF σ (prompt broadening) = {s['irf_sigma_bins']:.3f} bins")
81
+ print(f" IRF FWHM (effective) = {s['irf_fwhm_eff_ns']:.4f} ns")
82
+ if not is_dist and not is_tail and s.get('tail_amp', 0) > 0:
83
+ print(f" IRF tail amp = {s['tail_amp']:.4f}")
84
+ print(f" IRF tail τ = {s['tail_tau_ns']:.3f} ns")
85
+ if s['tail_tau_ns'] > 18:
86
+ print(f" tail τ near upper bound - consider acquiring a scatter PTU")
87
+ p_val = s.get('p_val')
88
+ p_str = f", p={p_val:.4f}" if p_val is not None else ""
89
+ print(f" χ²_r = {s['reduced_chi2']:.4f} "
90
+ f"(χ²={s['chi2']:.1f}, DoF={s['dof']}{p_str}) [full window, Neyman]")
91
+ print(f" χ²_r = {s['reduced_chi2_pearson']:.4f} "
92
+ f"[full window, Pearson]")
93
+ if not is_tail:
94
+ print(f" χ²_r = {s['reduced_chi2_tail']:.4f} "
95
+ f"(tail only, t>{s['tail_start_bin']*tcspc_res*1e9:.2f} ns) [Neyman]")
96
+ print(f" χ²_r = {s['reduced_chi2_tail_pearson']:.4f} "
97
+ f"(tail only, t>{s['tail_start_bin']*tcspc_res*1e9:.2f} ns) [Pearson]")
98
+ if p_val is not None:
99
+ flag = '' if 0.001 < p_val < 0.999 else ''
100
+ print(f" {flag} Optimizer: {s['optimizer_msg']}")
101
+ else:
102
+ print(f" Optimizer: {s['optimizer_msg']}")
103
+
104
+ def check_full_path(path):
105
+ if os.path.isabs(path) == True:
106
+ return path
107
+ else:
108
+ if os.path.isabs(os.getcwd() + '/' + path) == True:
109
+ return os.getcwd() + '/' + path
110
+ else:
111
+ raise Exception('Path not found')
@@ -0,0 +1,190 @@
1
+ import numpy as np
2
+ import matplotlib
3
+ import matplotlib.pyplot as plt
4
+ import matplotlib.gridspec as gridspec
5
+ from matplotlib.colors import LinearSegmentedColormap
6
+ from flimkit.configs import FLIM_CMAP
7
+
8
+ # Use non-interactive 'Agg' backend for thread-safe file saving
9
+ # This prevents segfaults when matplotlib operations happen in worker threads
10
+ try:
11
+ matplotlib.use('Agg', force=True)
12
+ except Exception:
13
+ pass
14
+
15
+
16
+ # rcParams for file exports - overrides any dark-theme globals set by the GUI
17
+ _EXPORT_RC = {
18
+ 'text.color': 'black',
19
+ 'axes.labelcolor': 'black',
20
+ 'xtick.color': 'black',
21
+ 'ytick.color': 'black',
22
+ 'axes.titlecolor': 'black',
23
+ }
24
+
25
+
26
+ def plot_summed(decay, summary, ptu, xlsx, n_exp, strategy, out_prefix,
27
+ irf_prompt=None):
28
+ plt.rcParams.update({'figure.dpi': 130, 'font.size': 10,
29
+ 'axes.spines.top': False, 'axes.spines.right': False,
30
+ **_EXPORT_RC})
31
+ s = summary
32
+ t_ns = np.arange(ptu.n_bins) * ptu.tcspc_res * 1e9
33
+ fs, fe = s['fit_window_bins']
34
+
35
+ fig = plt.figure(figsize=(12, 7))
36
+ gs = gridspec.GridSpec(2, 3, height_ratios=[3, 1], hspace=0.08, wspace=0.35)
37
+ ax1 = fig.add_subplot(gs[0, :2])
38
+ ax2 = fig.add_subplot(gs[1, :2], sharex=ax1)
39
+ ax3 = fig.add_subplot(gs[0, 2])
40
+ ax4 = fig.add_subplot(gs[1, 2])
41
+ ax4.axis('off')
42
+
43
+ ax1.semilogy(t_ns, np.clip(decay, 1, None), '.', color='#aaa',
44
+ ms=2, rasterized=True, label='PTU data')
45
+
46
+ # IRF - scale to ~10% of decay peak for visibility on log axis.
47
+ # Mask bins below 0.1% of IRF peak so zeros don't pollute the log axis.
48
+ if irf_prompt is not None:
49
+ scale = decay.max() * 0.1 / irf_prompt.max()
50
+ irf_scaled = irf_prompt * scale
51
+ irf_mask = irf_scaled > irf_scaled.max() * 1e-3
52
+ t_irf_plot = t_ns[irf_mask]
53
+ v_irf_plot = irf_scaled[irf_mask]
54
+ ax1.semilogy(t_irf_plot, v_irf_plot,
55
+ color='#f4a261', lw=1.5, ls='-.', alpha=0.85,
56
+ label=f"IRF (×{scale:.1e})")
57
+
58
+ if xlsx is not None and xlsx.get('fit_t') is not None and xlsx.get('fit_c') is not None:
59
+ ax1.semilogy(xlsx['fit_t'], np.clip(xlsx['fit_c'], 1, None),
60
+ 'b-', lw=1.1, alpha=0.55, label='FLIM microscope fit')
61
+ is_tail = s.get('fit_model') == 'tail'
62
+ ax1.semilogy(t_ns, np.clip(s['model'], 1, None), 'r-', lw=2,
63
+ label=f"{n_exp}-exp {'tail' if is_tail else 'reconv.'}")
64
+ ax1.set_xlim(0, min(t_ns[-1], 22))
65
+ ax1.set_ylabel('Counts')
66
+ ax1.legend(fontsize=8, loc='upper right')
67
+ ax1.set_title(f"Summed Decay - {n_exp}-exp "
68
+ f"{'tail fit (no IRF)' if is_tail else '| IRF: ' + strategy}",
69
+ fontweight='bold')
70
+ ax1.axvspan(s['fit_window_ns'][0], s['fit_window_ns'][1],
71
+ alpha=0.06, color='green')
72
+ plt.setp(ax1.get_xticklabels(), visible=False)
73
+
74
+ ax2.axhline(0, color='k', lw=0.8, ls='--')
75
+ ax2.fill_between(t_ns[fs:fe], np.clip(s['residuals'][fs:fe], -5, 5),
76
+ alpha=0.5, color='#457b9d')
77
+ ax2.set_ylim(-5, 5)
78
+ ax2.set_xlim(0, min(t_ns[-1], 22))
79
+ ax2.set_xlabel('Time (ns)')
80
+ ax2.set_ylabel('W. Residuals')
81
+
82
+ rv = np.clip(s['residuals'][fs:fe], -5, 5)
83
+ ax3.hist(rv, bins=60, color='#2a9d8f', edgecolor='none', alpha=0.85)
84
+ ax3.axvline(0, color='k', lw=0.8)
85
+ ax3.set_xlabel('Weighted residual')
86
+ ax3.set_ylabel('Frequency')
87
+ ax3.set_title(f"Residuals μ={rv.mean():.3f} σ={rv.std():.3f}")
88
+
89
+ p_val = s.get('p_val')
90
+ p_line = f"p = {p_val:.4f}" if p_val is not None else ""
91
+ lines = [f"χ²_r = {s['reduced_chi2_pearson']:.4f} (Pearson)",
92
+ f"χ²_r(tail) = {s['reduced_chi2_tail_pearson']:.4f} (Pearson)",
93
+ p_line,
94
+ f"bg = {s['bg_fit']:.1f} cts/bin",
95
+ f"τ_mean(int) = {s['tau_mean_int_ns']:.4f} ns",
96
+ f"τ_mean(amp) = {s['tau_mean_amp_ns']:.4f} ns",
97
+ (f"t0 = {s['t0_ns']:.4f} ns" if is_tail
98
+ else f"IRF FWHM(eff) = {s['irf_fwhm_eff_ns']:.4f} ns"), '']
99
+ if 'tau_centers_ns' in s:
100
+ width_label = 'σ' if s['dist_type'] == 'gaussian' else 'Γ'
101
+ for i, (tau_c, w, frac) in enumerate(
102
+ zip(s['tau_centers_ns'], s['widths_ns'], s['fractions'])):
103
+ lines.append(f"τ_c{i+1}={tau_c:.4f} ns {width_label}={w:.4f} ns f{i+1}={frac:.4f}")
104
+ else:
105
+ for i, (tau, frac) in enumerate(zip(s['taus_ns'], s['fractions'])):
106
+ lines.append(f"τ{i+1}={tau:.4f} ns f{i+1}={frac:.4f}")
107
+ ax4.text(0.05, 0.97, '\n'.join(lines), transform=ax4.transAxes,
108
+ va='top', fontsize=9, family='monospace',
109
+ bbox=dict(boxstyle='round,pad=0.4', fc='#f7f7f7', alpha=0.9))
110
+
111
+ plt.suptitle(f"FLIM {'Tail' if is_tail else 'Reconvolution'} Fit - "
112
+ f"FLIM microscope / PicoHarp",
113
+ fontsize=12, fontweight='bold')
114
+ out = f"{out_prefix}_summed_{n_exp}exp.png"
115
+ plt.savefig(out, dpi=150, bbox_inches='tight')
116
+ plt.close()
117
+ print(f" Saved: {out}")
118
+
119
+
120
+ def plot_pixel_maps(maps, n_exp, out_prefix, binning=1):
121
+ fig, axes = plt.subplots(2, 3, figsize=(15, 9))
122
+ fig.patch.set_facecolor('#111')
123
+
124
+ def _show(ax, data, title, cmap='viridis', vmin=None, vmax=None, unit='ns'):
125
+ ax.set_facecolor('#111')
126
+ if data is None:
127
+ ax.set_visible(False); return
128
+ valid = data[np.isfinite(data) & (data > 0)]
129
+ if len(valid) == 0:
130
+ ax.set_visible(False); return
131
+ vlo = vmin if vmin is not None else np.percentile(valid, 2)
132
+ vhi = vmax if vmax is not None else np.percentile(valid, 98)
133
+ im = ax.imshow(data, cmap=cmap, vmin=vlo, vmax=vhi, interpolation='nearest')
134
+ cb = plt.colorbar(im, ax=ax, fraction=0.046, pad=0.04)
135
+ cb.set_label(unit, color='white')
136
+ cb.ax.yaxis.set_tick_params(color='white')
137
+ plt.setp(cb.ax.yaxis.get_ticklabels(), color='white')
138
+ ax.set_title(title, color='white', fontweight='bold')
139
+ ax.set_axis_off()
140
+
141
+ _show(axes[0, 0], maps['intensity'], 'Intensity', 'hot', unit='photons')
142
+ _show(axes[0, 1], maps['tau_mean_int'], 'τ_mean (int.-wt.)', FLIM_CMAP)
143
+ _show(axes[0, 2], maps['tau_mean_amp'], 'τ_mean (amp.-wt.)', FLIM_CMAP)
144
+ for i in range(min(n_exp, 3)):
145
+ _show(axes[1, i], maps.get(f"frac_{i+1}"), f"f{i+1}",
146
+ 'viridis', vmin=0, vmax=1, unit='fraction')
147
+
148
+ plt.suptitle(f"FLIM Pixel Maps - {n_exp}-exp (τ fixed, α free) "
149
+ f"binning={binning}×{binning}",
150
+ color='white', fontsize=12, fontweight='bold')
151
+ plt.tight_layout()
152
+ out = f"{out_prefix}_pixelmaps_{n_exp}exp.png"
153
+ plt.savefig(out, dpi=150, bbox_inches='tight', facecolor='#111')
154
+ plt.close()
155
+ print(f" Saved: {out}")
156
+
157
+
158
+ def plot_lifetime_histogram(maps, n_exp, out_prefix):
159
+ plt.rcParams.update(_EXPORT_RC)
160
+ tau = maps['tau_mean_int']
161
+ wt = maps['intensity']
162
+ ok = np.isfinite(tau) & (wt > 0)
163
+ if ok.sum() < 2:
164
+ return
165
+ tau_ok = tau[ok]
166
+ data_range = float(tau_ok.max() - tau_ok.min())
167
+ # Skip histogram if range is too narrow to bin meaningfully
168
+ if data_range < 1e-12:
169
+ print(f" Skipping lifetime histogram - τ range too narrow ({tau_ok[0]:.4f} ns)")
170
+ return
171
+ mu_w = np.average(tau_ok, weights=wt[ok])
172
+ n_bins = min(100, max(2, int(np.sqrt(ok.sum()))))
173
+ # Clamp bins so each bin spans at least ~1e-12
174
+ max_bins = max(1, int(data_range / 1e-12))
175
+ n_bins = min(n_bins, max_bins)
176
+ fig, ax = plt.subplots(figsize=(7, 4))
177
+ ax.hist(tau_ok, bins=n_bins, weights=wt[ok], color='#2a9d8f', alpha=0.85)
178
+ ax.axvline(mu_w, color='red', ls='--', lw=1.5,
179
+ label=f"Weighted mean = {mu_w:.3f} ns")
180
+ ax.set_xlabel('τ_mean (intensity-weighted) [ns]')
181
+ ax.set_ylabel('Photon-weighted frequency')
182
+ ax.set_title(f"Lifetime Distribution - {n_exp}-exp", fontweight='bold')
183
+ ax.legend()
184
+ ax.spines['top'].set_visible(False)
185
+ ax.spines['right'].set_visible(False)
186
+ plt.tight_layout()
187
+ out = f"{out_prefix}_lifetime_hist_{n_exp}exp.png"
188
+ plt.savefig(out, dpi=150, bbox_inches='tight')
189
+ plt.close()
190
+ print(f" Saved: {out}")
flimkit/utils/roi.py ADDED
@@ -0,0 +1,370 @@
1
+ import copy
2
+ import json
3
+ from typing import Any, List, Dict, Optional, Tuple
4
+ import numpy as np
5
+
6
+ _COLORS = [
7
+ '#FF6B6B',
8
+ '#4ECDC4',
9
+ '#FFE66D',
10
+ '#95E1D3',
11
+ '#C7CEEA',
12
+ '#FF8C42',
13
+ ]
14
+
15
+ class RoiManager:
16
+
17
+ def __init__(self):
18
+ self.regions: List[Dict] = []
19
+ self._next_id = 0
20
+ self._selected_id: Optional[int] = None
21
+
22
+ def add_region(self, name: str, tool_type: str, coords: List[List[float]],
23
+ color_idx: Optional[int] = None) -> int:
24
+ if tool_type not in ('rect', 'ellipse', 'polygon', 'freehand'):
25
+ raise ValueError(f'Invalid tool_type: {tool_type}')
26
+ if not coords or len(coords) == 0:
27
+ raise ValueError('coords cannot be empty')
28
+ if color_idx is None:
29
+ color_idx = len(self.regions) % len(_COLORS)
30
+ region = {
31
+ 'id': self._next_id,
32
+ 'name': name,
33
+ 'tool': tool_type,
34
+ 'coords': [[float(x), float(y)] for x, y in coords],
35
+ 'color_idx': int(color_idx),
36
+ }
37
+ self.regions.append(region)
38
+ self._next_id += 1
39
+ return region['id']
40
+
41
+ def remove_region(self, region_id: int) -> bool:
42
+ for i, r in enumerate(self.regions):
43
+ if r['id'] == region_id:
44
+ self.regions.pop(i)
45
+ if self._selected_id == region_id:
46
+ self._selected_id = None
47
+ return True
48
+ return False
49
+
50
+ def get_region(self, region_id: int) -> Optional[Dict]:
51
+ for r in self.regions:
52
+ if r['id'] == region_id:
53
+ return r
54
+ return None
55
+
56
+ def update_region(self, region_id: int, **kwargs) -> bool:
57
+ for r in self.regions:
58
+ if r['id'] == region_id:
59
+ if 'coords' in kwargs:
60
+ r['coords'] = [[float(x), float(y)] for x, y in kwargs['coords']]
61
+ if 'name' in kwargs:
62
+ r['name'] = str(kwargs['name'])
63
+ if 'color_idx' in kwargs:
64
+ r['color_idx'] = int(kwargs['color_idx'])
65
+ if 'tool' in kwargs:
66
+ r['tool'] = str(kwargs['tool'])
67
+ return True
68
+ return False
69
+
70
+ def select_region(self, region_id: Optional[int]) -> None:
71
+ self._selected_id = region_id
72
+
73
+ def get_selected_id(self) -> Optional[int]:
74
+ return self._selected_id
75
+
76
+ def get_all_regions(self) -> List[Dict]:
77
+ return self.regions
78
+
79
+ def clear_all(self) -> None:
80
+ self.regions = []
81
+ self._selected_id = None
82
+ self._next_id = 0
83
+
84
+ def to_json(self) -> str:
85
+ data = {
86
+ 'regions': self.regions,
87
+ 'next_id': self._next_id,
88
+ }
89
+ return json.dumps(data, default=str)
90
+
91
+ @classmethod
92
+ def from_json(cls, json_str: str) -> 'RoiManager':
93
+ manager = cls()
94
+ try:
95
+ data = json.loads(json_str)
96
+ manager.regions = data.get('regions', [])
97
+ manager._next_id = data.get('next_id', len(manager.regions))
98
+ except (json.JSONDecodeError, ValueError):
99
+ pass
100
+ return manager
101
+
102
+ @staticmethod
103
+ def _geojson_coordinates(values: Any, minimum: int) -> List[List[float]]:
104
+ if not isinstance(values, list):
105
+ raise ValueError('GeoJSON coordinates must be a list')
106
+ coords = []
107
+ for point in values:
108
+ if not isinstance(point, (list, tuple)) or len(point) < 2:
109
+ raise ValueError('GeoJSON coordinates must contain [x, y] points')
110
+ x, y = float(point[0]), float(point[1])
111
+ if not np.isfinite(x) or not np.isfinite(y):
112
+ raise ValueError('GeoJSON coordinates must be finite')
113
+ coords.append([x, y])
114
+ if len(coords) < minimum:
115
+ raise ValueError(f'GeoJSON geometry requires at least {minimum} points')
116
+ return coords
117
+
118
+ @staticmethod
119
+ def _outer_boundary(ring: List[List[float]]) -> Optional[List[List[float]]]:
120
+ from shapely.geometry import MultiPolygon, Polygon
121
+ polygon = Polygon(ring)
122
+ if polygon.is_valid:
123
+ return None
124
+ repaired = polygon.buffer(0)
125
+ if isinstance(repaired, MultiPolygon):
126
+ if not repaired.geoms:
127
+ return None
128
+ repaired = max(repaired.geoms, key=lambda part: part.area)
129
+ if repaired.is_empty or repaired.geom_type != 'Polygon':
130
+ return None
131
+ out = [[float(x), float(y)] for x, y in repaired.exterior.coords]
132
+ if len(out) < 4:
133
+ return None
134
+ if out[-1] != out[0]:
135
+ out.append(out[0][:])
136
+ return out
137
+
138
+ @staticmethod
139
+ def _region_feature(region: Dict) -> Dict:
140
+ tool = region['tool']
141
+ coords = [[float(x), float(y)] for x, y in region['coords']]
142
+ properties = {
143
+ 'id': region.get('id'),
144
+ 'name': region.get('name', ''),
145
+ 'tool_type': tool,
146
+ 'color_idx': region.get('color_idx', 0),
147
+ }
148
+ statistic_keys = (
149
+ 'tau_median', 'tau_stdev', 'photon_count', 'photon_stdev',
150
+ )
151
+ statistics = {
152
+ key: region.get('statistics', {}).get(key)
153
+ for key in statistic_keys
154
+ if region.get('statistics', {}).get(key) is not None
155
+ }
156
+ properties['statistics'] = statistics
157
+ if tool == 'rect':
158
+ if len(coords) != 2:
159
+ raise ValueError('Rectangle regions require two corner points')
160
+ (x1, y1), (x2, y2) = coords
161
+ ring = [
162
+ [x1, y1], [x2, y1], [x2, y2], [x1, y2], [x1, y1],
163
+ ]
164
+ properties['bounds'] = coords
165
+ elif tool == 'ellipse':
166
+ if len(coords) != 2:
167
+ raise ValueError('Ellipse regions require two corner points')
168
+ (x1, y1), (x2, y2) = coords
169
+ cx, cy = (x1 + x2) / 2, (y1 + y2) / 2
170
+ rx, ry = abs(x2 - x1) / 2, abs(y2 - y1) / 2
171
+ angles = np.linspace(0.0, 2.0 * np.pi, 64, endpoint=False)
172
+ ring = [
173
+ [float(cx + rx * np.cos(angle)),
174
+ float(cy + ry * np.sin(angle))]
175
+ for angle in angles
176
+ ]
177
+ ring.append(ring[0].copy())
178
+ properties['bounds'] = coords
179
+ elif tool in ('polygon', 'freehand'):
180
+ if len(coords) < 3:
181
+ raise ValueError(f'{tool.capitalize()} regions require at least three points')
182
+ ring = coords.copy()
183
+ if ring[-1] != ring[0]:
184
+ ring.append(ring[0].copy())
185
+ outer = RoiManager._outer_boundary(ring)
186
+ if outer is not None:
187
+ ring = outer
188
+ properties['repaired'] = 'self-intersecting'
189
+ else:
190
+ raise ValueError(f'Unsupported ROI tool: {tool}')
191
+ return {
192
+ 'type': 'Feature',
193
+ 'properties': properties,
194
+ 'geometry': {'type': 'Polygon', 'coordinates': [ring]},
195
+ }
196
+
197
+ def to_geojson(self, region_ids: Optional[List[int]] = None) -> Dict:
198
+ if region_ids is None:
199
+ regions = self.regions
200
+ else:
201
+ wanted = set(region_ids)
202
+ regions = [region for region in self.regions if region['id'] in wanted]
203
+ found = {region['id'] for region in regions}
204
+ missing = wanted - found
205
+ if missing:
206
+ raise ValueError(f'Region IDs not found: {sorted(missing)}')
207
+ return {
208
+ 'type': 'FeatureCollection',
209
+ 'features': [self._region_feature(region) for region in regions],
210
+ 'flimkit': {
211
+ 'coordinate_system': 'image-pixel',
212
+ 'axis_order': 'xy',
213
+ 'origin': 'top-left',
214
+ },
215
+ }
216
+
217
+ @classmethod
218
+ def _region_from_feature(cls, feature: Dict) -> Dict:
219
+ if not isinstance(feature, dict) or feature.get('type') != 'Feature':
220
+ raise ValueError('GeoJSON entries must be Features')
221
+ properties = feature.get('properties') or {}
222
+ geometry = feature.get('geometry') or {}
223
+ if not isinstance(properties, dict) or not isinstance(geometry, dict):
224
+ raise ValueError('GeoJSON Feature properties and geometry must be objects')
225
+ geometry_type = geometry.get('type')
226
+ raw_coordinates = geometry.get('coordinates')
227
+ tool = properties.get('tool_type')
228
+ if tool is None:
229
+ if geometry_type == 'Polygon':
230
+ tool = 'polygon'
231
+ elif geometry_type == 'LineString':
232
+ tool = 'freehand'
233
+ if tool not in ('rect', 'ellipse', 'polygon', 'freehand'):
234
+ raise ValueError(f'Unsupported GeoJSON geometry: {geometry_type}')
235
+ if geometry_type == 'Polygon':
236
+ if not isinstance(raw_coordinates, list) or not raw_coordinates:
237
+ raise ValueError('GeoJSON Polygon must contain an outer ring')
238
+ outer_ring = raw_coordinates[0]
239
+ if isinstance(outer_ring, list) and len(outer_ring) < 4:
240
+ raise ValueError(
241
+ 'GeoJSON Polygon outer rings require at least four '
242
+ 'positions, including a repeated closing position',
243
+ )
244
+ ring = cls._geojson_coordinates(outer_ring, 4)
245
+ if ring[0] == ring[-1]:
246
+ ring = ring[:-1]
247
+ if tool in ('rect', 'ellipse'):
248
+ bounds = properties.get('bounds')
249
+ if bounds is not None:
250
+ coords = cls._geojson_coordinates(bounds, 2)
251
+ if len(coords) != 2:
252
+ raise ValueError('GeoJSON ROI bounds require two corner points')
253
+ else:
254
+ xs = [point[0] for point in ring]
255
+ ys = [point[1] for point in ring]
256
+ coords = [[min(xs), min(ys)], [max(xs), max(ys)]]
257
+ else:
258
+ coords = cls._geojson_coordinates(ring, 3)
259
+ elif geometry_type == 'LineString' and tool in ('polygon', 'freehand'):
260
+ coords = cls._geojson_coordinates(raw_coordinates, 3)
261
+ else:
262
+ raise ValueError(f'Unsupported GeoJSON geometry: {geometry_type}')
263
+ statistics = properties.get('statistics')
264
+ if isinstance(statistics, dict):
265
+ statistics = copy.deepcopy(statistics)
266
+ else:
267
+ statistics = {}
268
+ for key in ('tau_median', 'tau_stdev', 'photon_count', 'photon_stdev'):
269
+ if key not in statistics and properties.get(key) is not None:
270
+ statistics[key] = properties[key]
271
+ color_idx = properties.get('color_idx')
272
+ return {
273
+ 'name': str(properties.get('name', 'imported-region')),
274
+ 'tool': tool,
275
+ 'coords': coords,
276
+ 'color_idx': int(color_idx) if color_idx is not None else None,
277
+ 'statistics': statistics,
278
+ }
279
+
280
+ def add_geojson(self, payload: Dict, mode: str = 'append') -> List[int]:
281
+ if mode not in ('append', 'replace'):
282
+ raise ValueError('mode must be append or replace')
283
+ if not isinstance(payload, dict):
284
+ raise ValueError('GeoJSON payload must be an object')
285
+ if payload.get('type') == 'FeatureCollection':
286
+ features = payload.get('features')
287
+ if not isinstance(features, list):
288
+ raise ValueError('GeoJSON FeatureCollection features must be a list')
289
+ elif payload.get('type') == 'Feature':
290
+ features = [payload]
291
+ else:
292
+ raise ValueError('GeoJSON must be a Feature or FeatureCollection')
293
+ pending = [self._region_from_feature(feature) for feature in features]
294
+ if mode == 'replace':
295
+ self.clear_all()
296
+ added = []
297
+ for item in pending:
298
+ region_id = self.add_region(
299
+ item['name'], item['tool'], item['coords'], item['color_idx'],
300
+ )
301
+ region = self.get_region(region_id)
302
+ if region is not None and item['statistics']:
303
+ region['statistics'] = item['statistics']
304
+ added.append(region_id)
305
+ return added
306
+
307
+ def compute_region_mask(self, region_id: int, image_shape: Tuple[int, int]) -> Optional[np.ndarray]:
308
+ from matplotlib.path import Path as MplPath
309
+ region = self.get_region(region_id)
310
+ if region is None:
311
+ return None
312
+ height, width = image_shape
313
+ mask = np.zeros((height, width), dtype=bool)
314
+ coords = np.array(region['coords'], dtype=float)
315
+ if region['tool'] == 'rect':
316
+ if len(coords) >= 2:
317
+ x0, y0 = coords[0]
318
+ x1, y1 = coords[1]
319
+ x_min, x_max = int(min(x0, x1)), int(max(x0, x1))
320
+ y_min, y_max = int(min(y0, y1)), int(max(y0, y1))
321
+ mask[y_min:y_max+1, x_min:x_max+1] = True
322
+ elif region['tool'] == 'ellipse':
323
+ if len(coords) >= 2:
324
+ x0, y0 = coords[0]
325
+ x1, y1 = coords[1]
326
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
327
+ rx, ry = abs(x1 - x0) / 2, abs(y1 - y0) / 2
328
+ yy, xx = np.ogrid[:height, :width]
329
+ mask = ((xx - cx)**2 / (rx**2 + 1e-6) +
330
+ (yy - cy)**2 / (ry**2 + 1e-6)) <= 1
331
+ elif region['tool'] in ('polygon', 'freehand'):
332
+ if len(coords) >= 3:
333
+ path = MplPath(coords)
334
+ yy, xx = np.meshgrid(np.arange(height), np.arange(width), indexing='ij')
335
+ points = np.column_stack([xx.ravel(), yy.ravel()])
336
+ mask = path.contains_points(points).reshape((height, width))
337
+ return mask
338
+
339
+ def get_color(self, region_id: int) -> str:
340
+ region = self.get_region(region_id)
341
+ if region is None:
342
+ return '#999999'
343
+ color_idx = region.get('color_idx', 0) % len(_COLORS)
344
+ return _COLORS[color_idx]
345
+
346
+ @staticmethod
347
+ def get_color_palette() -> List[str]:
348
+ return _COLORS.copy()
349
+
350
+ def get_rectangle_patch(coords, edgecolor, facecolor='none', linewidth=2):
351
+ from matplotlib.patches import Rectangle
352
+ x0, y0 = coords[0]
353
+ x1, y1 = coords[1]
354
+ width = abs(x1 - x0)
355
+ height = abs(y1 - y0)
356
+ xy = (min(x0, x1), min(y0, y1))
357
+ return Rectangle(xy, width, height, edgecolor=edgecolor, facecolor=facecolor, linewidth=linewidth)
358
+
359
+ def get_ellipse_patch(coords, edgecolor, facecolor='none', linewidth=2):
360
+ from matplotlib.patches import Ellipse
361
+ x0, y0 = coords[0]
362
+ x1, y1 = coords[1]
363
+ cx, cy = (x0 + x1) / 2, (y0 + y1) / 2
364
+ width = abs(x1 - x0)
365
+ height = abs(y1 - y0)
366
+ return Ellipse((cx, cy), width, height, edgecolor=edgecolor, facecolor=facecolor, linewidth=linewidth)
367
+
368
+ def get_polygon_patch(coords, edgecolor, facecolor='none', linewidth=2):
369
+ from matplotlib.patches import Polygon
370
+ return Polygon(coords, edgecolor=edgecolor, facecolor=facecolor, linewidth=linewidth, closed=True)