flimkit-web-ui 0.1.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.
@@ -0,0 +1,18 @@
1
+ import webbrowser
2
+
3
+ from flimkit.plugins import startup, tool
4
+
5
+ from . import server
6
+
7
+ FLIMKIT_PLUGIN_API = 1
8
+
9
+
10
+ @startup(id='web_ui', order=900)
11
+ def launch_web_ui(app):
12
+ server.start(app)
13
+
14
+
15
+ @tool(id='open_web_ui', label='Open Web UI', menu='Tools', order=850)
16
+ def open_web_ui(app):
17
+ server.start(app)
18
+ webbrowser.open(server.url())
flimkit_web_ui/api.py ADDED
@@ -0,0 +1,677 @@
1
+ import csv
2
+ import io
3
+ import os
4
+ import tempfile
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+
9
+ from . import bridge
10
+
11
+ EXTRA_VARS = {
12
+ 'irf_fov.method': lambda app: app._irf_fov.sv_method,
13
+ 'irf_fov.path': lambda app: app._irf_fov.sv_path,
14
+ 'irf_st.method': lambda app: app._irf_st.sv_method,
15
+ 'irf_st.path': lambda app: app._irf_st.sv_path,
16
+ 'fov.tau_min': lambda app: app._fov_preview._sv_tau_min,
17
+ 'fov.tau_max': lambda app: app._fov_preview._sv_tau_max,
18
+ 'fov.gamma': lambda app: app._fov_preview._sv_gamma,
19
+ 'fov.cmap': lambda app: app._fov_preview._sv_cmap,
20
+ 'fov.show_decay': lambda app: app._fov_preview._bv_show_decay,
21
+ 'fov.view': lambda app: app._fov_preview._sv_display_mode,
22
+ 'fov.weighting': lambda app: app._fov_preview._sv_tau_weighting,
23
+ 'ph.mode': lambda app: app._phasor_panel._mode_var,
24
+ 'ph.radius': lambda app: app._phasor_panel._radius,
25
+ 'ph.ratio': lambda app: app._phasor_panel._ratio,
26
+ 'ph.filter_method': lambda app: app._phasor_panel._filter_method,
27
+ 'ph.filter_sigma': lambda app: app._phasor_panel._filter_sigma,
28
+ 'ph.filter_size': lambda app: app._phasor_panel._filter_size,
29
+ }
30
+
31
+
32
+ def _tog(app, var, entry):
33
+ from flimkit.UI.utils import _tog as tog
34
+ tog(getattr(app, var), getattr(app, entry))
35
+
36
+
37
+ def _tl_entries(app):
38
+ state = 'normal' if app.bv_tl_fix_tau.get() == True else 'disabled'
39
+ for e in (app._tl_tau1_e, app._tl_tau2_e, app._tl_tau3_e):
40
+ e.config(state=state)
41
+
42
+
43
+ AFTER_SET = {
44
+ 'sv_fov_analysis': lambda app: app._on_fov_analysis_changed(),
45
+ 'bv_thr_fov': lambda app: _tog(app, 'bv_thr_fov', '_thr_fov_e'),
46
+ 'bv_thr_st': lambda app: _tog(app, 'bv_thr_st', '_thr_st_e'),
47
+ 'bv_batch_thr': lambda app: _tog(app, 'bv_batch_thr', '_batch_thr_e'),
48
+ 'bv_tl_fix_tau': _tl_entries,
49
+ 'sv_pipeline': lambda app: app._pipeline_changed(),
50
+ 'bv_perpix': lambda app: app._perpix_toggled(),
51
+ 'sv_batch_mode': lambda app: app._batch_mode_changed(),
52
+ 'sv_ph_mode': lambda app: app._ph_mode_changed(),
53
+ 'irf_fov.method': lambda app: app._irf_fov._update(),
54
+ 'irf_st.method': lambda app: app._irf_st._update(),
55
+ 'fov.show_decay': lambda app: app._fov_preview._toggle_decay(),
56
+ 'fov.view': lambda app: app._fov_preview._on_display_mode_changed(),
57
+ 'fov.weighting': lambda app: app._fov_preview._on_weighting_changed(),
58
+ 'ph.mode': lambda app: app._phasor_panel._on_mode_change(),
59
+ 'ph.radius': lambda app: app._phasor_panel._on_param_change(),
60
+ 'ph.ratio': lambda app: app._phasor_panel._on_param_change(),
61
+ }
62
+
63
+
64
+ def _var_table(app):
65
+ table = {}
66
+ for nam, var in app.state.__dict__.items():
67
+ if callable(getattr(var, 'get', None)) and callable(getattr(var, 'set', None)):
68
+ table[nam] = var
69
+ for nam, get in EXTRA_VARS.items():
70
+ try:
71
+ table[nam] = get(app)
72
+ except AttributeError:
73
+ pass
74
+ return table
75
+
76
+
77
+ def _coerce(var, value):
78
+ import tkinter as tk
79
+ if isinstance(var, tk.BooleanVar):
80
+ return value == True or str(value).lower() == 'true'
81
+ if isinstance(var, tk.IntVar):
82
+ return int(float(value))
83
+ if isinstance(var, tk.DoubleVar):
84
+ return float(value)
85
+ return '' if value is None else str(value)
86
+
87
+
88
+ def set_fields(app, values):
89
+ bridge.mark_action()
90
+
91
+ def do():
92
+ table = _var_table(app)
93
+ for key, value in values.items():
94
+ if key not in table:
95
+ raise ValueError('Unknown field ' + key)
96
+ var = table[key]
97
+ var.set(_coerce(var, value))
98
+ if key in AFTER_SET:
99
+ AFTER_SET[key](app)
100
+
101
+ bridge.on_ui(app, do)
102
+ return bridge.drain_notes()
103
+
104
+
105
+ def roi_panel(app):
106
+ return app._fov_preview._roi_analysis_panel or app._roi_analysis_panel
107
+
108
+
109
+ def _btn_state(btn):
110
+ return str(btn.cget('state')) == 'disabled'
111
+
112
+
113
+ def _btn_text(btn):
114
+ return str(btn.cget('text')).lstrip('▶ ').strip()
115
+
116
+
117
+ def expert_values(app):
118
+ from flimkit.UI.expert_settings import _EXPERT_DEFAULTS
119
+ from flimkit.UI.utils import _C
120
+ c = _C()
121
+ vals = dict(_EXPERT_DEFAULTS)
122
+ vals.update({
123
+ 'binning_factor': c['binning_factor'],
124
+ 'optimizer': c['Optimizer'],
125
+ 'lm_restarts': c['lm_restarts'],
126
+ 'de_population': c['de_population'],
127
+ 'de_maxiter': c['de_maxiter'],
128
+ 'n_workers': c['n_workers'],
129
+ 'min_photons': c['MIN_PHOTONS_PERPIX'],
130
+ })
131
+ try:
132
+ from flimkit.utils.config_manager import cfg
133
+ vals.update(cfg.get_section('expert') or {})
134
+ except Exception:
135
+ pass
136
+ vals.update(app._expert_overrides or {})
137
+ return vals
138
+
139
+
140
+ def _cursor_json(cur):
141
+ if cur.get('type', 'ellipse') == 'poly':
142
+ return {'type': 'poly', 'color': cur['color'], 'vertices': [[float(v[0]), float(v[1])] for v in cur['vertices']]}
143
+ return {'type': 'ellipse', 'color': cur['color'], 'g': float(cur['center_g']), 's': float(cur['center_s'])}
144
+
145
+
146
+ def get_state(app):
147
+ bridge.mark_poll()
148
+
149
+ def read():
150
+ from flimkit.UI.irf_widget import IRFWidget
151
+ from flimkit.utils import display
152
+ p = app._fov_preview
153
+ res = app._res
154
+ panel = roi_panel(app)
155
+ ph = app._phasor_panel
156
+ pb = app._proj_browser
157
+ fields = {}
158
+ for key, var in _var_table(app).items():
159
+ try:
160
+ fields[key] = var.get()
161
+ except Exception:
162
+ pass
163
+ stack = p._series or p._zstack
164
+ mgr = p._roi_manager
165
+ rois = []
166
+ for iid in panel._tree.get_children():
167
+ rid = int(iid)
168
+ rois.append({'id': rid, 'values': [str(v) for v in panel._tree.item(iid, 'values')], 'color': mgr.get_color(rid)})
169
+ fit = res._fit_result or {}
170
+ try:
171
+ roi_defaults = app._get_roi_fit_params()
172
+ roi_defaults = {k: roi_defaults.get(k) for k in ('n_exp', 'tau_min', 'tau_max', 'cost_function')}
173
+ except Exception:
174
+ roi_defaults = {'n_exp': 1, 'tau_min': 0.1, 'tau_max': 25.0, 'cost_function': 'poisson'}
175
+ log = res.log.get('1.0', 'end')
176
+ buttons = {'fov': app._btn_fov, 'stitch': app._btn_st, 'phasor': app._btn_ph,
177
+ 'batch': app._btn_batch, 'irf': app._btn_mirf}
178
+ progress = bridge.progress_state()
179
+ return {
180
+ 'form': getattr(app, '_current_form', 'fov'),
181
+ 'fields': fields,
182
+ 'choices': {'irf': [list(c) for c in IRFWidget.CHOICES], 'cmap': list(display.COLORMAPS.keys())},
183
+ 'run_labels': {k: _btn_text(b) for k, b in buttons.items()},
184
+ 'busy': {k: _btn_state(b) for k, b in buttons.items()},
185
+ 'running': any(_btn_state(b) for b in (app._btn_fov, app._btn_st, app._btn_ph)) or len(progress) > 0,
186
+ 'progress': progress,
187
+ 'preview_status': p._status.get(),
188
+ 'results_status': res._status.get(),
189
+ 'scale_controls': bool(p._ctrl_frame.grid_info()),
190
+ 'z': {'i': p._z_i, 'n': len(stack), 'label': p._z_label.get()} if stack else None,
191
+ 'summary': [[str(v) for v in res._tv.item(i, 'values')] for i in res._tv.get_children()],
192
+ 'log_tail': log[-20000:],
193
+ 'log_len': len(log),
194
+ 'images': [str(x) for x in res._imgs],
195
+ 'export_images': sorted(k for k, v in fit.items()
196
+ if isinstance(v, np.ndarray) and (v.ndim == 2 or (v.ndim == 3 and v.shape[2] == 3))),
197
+ 'output_dir': str(res._output_dir or ''),
198
+ 'npz_path': str(res._current_npz_path or ''),
199
+ 'rois': rois,
200
+ 'roi_selected': [int(i) for i in panel._tree.selection()],
201
+ 'roi_mode': panel._current_mode.get(),
202
+ 'roi_status': panel._status.get(),
203
+ 'roi_fits': [list(k) for k in panel._last_fit_results.keys()],
204
+ 'roi_can_fit': callable(getattr(panel, 'get_fit_params', None)),
205
+ 'roi_defaults': roi_defaults,
206
+ 'expert': expert_values(app),
207
+ 'expert_active': bool(app._expert_overrides),
208
+ 'phasor': {
209
+ 'loaded': ph._real is not None,
210
+ 'status': ph._status_var.get(),
211
+ 'freq': float(ph._freq or 0.0),
212
+ 'cursors': [_cursor_json(c) for c in ph._cursors],
213
+ 'max_cursors': ph.max_cursors,
214
+ 'has_fit': ph._last_fit_result is not None,
215
+ 'peaks': [] if ph._peak_results is None else [
216
+ {'g': float(ph._peak_results['peak_g'][i]), 's': float(ph._peak_results['peak_s'][i]),
217
+ 'tau': float(ph._peak_results['tau_phase'][i])}
218
+ for i in range(int(ph._peak_results['n_peaks']))],
219
+ },
220
+ 'batch': {
221
+ 'label': str(app._batch_mode_label.cget('text')),
222
+ 'help': str(app._batch_io_help.cget('text')),
223
+ 'beside': app.sv_batch_save_beside_preview.get(),
224
+ },
225
+ 'project': {
226
+ 'status': pb._sv_status.get(),
227
+ 'items': [str(x) for x in pb._lb.get(0, 'end')],
228
+ 'selected': [int(i) for i in pb._lb.curselection()],
229
+ },
230
+ 'recent': [dict(e) for e in app._recent_files],
231
+ 'figs': bridge.fig_revs(app),
232
+ }
233
+
234
+ state = bridge.on_ui(app, read, timeout=8.0)
235
+ state['notes'] = bridge.drain_notes()
236
+ return state
237
+
238
+
239
+ def list_dir(directory):
240
+ directory = os.path.abspath(os.path.expanduser(directory or '~'))
241
+ if os.path.isdir(directory) == False:
242
+ directory = os.path.expanduser('~')
243
+ entries = []
244
+ try:
245
+ with os.scandir(directory) as it:
246
+ for entry in it:
247
+ if entry.name.startswith('.'):
248
+ continue
249
+ try:
250
+ is_dir = entry.is_dir()
251
+ except OSError:
252
+ continue
253
+ entries.append({'name': entry.name, 'path': entry.path, 'is_dir': is_dir})
254
+ except OSError as exc:
255
+ return {'error': str(exc)}
256
+ entries.sort(key=lambda e: (e['is_dir'] == False, e['name'].lower()))
257
+ parent = os.path.dirname(directory)
258
+ return {'dir': directory, 'parent': parent if parent != directory else None, 'entries': entries}
259
+
260
+
261
+ def _stem(app):
262
+ ptu = app._fov_preview._ptu_path
263
+ return Path(str(ptu)).stem if ptu else 'flimkit'
264
+
265
+
266
+ def _select(app, ids):
267
+ panel = roi_panel(app)
268
+ p = app._fov_preview
269
+ current = panel._tree.selection()
270
+ if current:
271
+ panel._tree.selection_remove(current)
272
+ if ids:
273
+ panel._tree.selection_set([str(i) for i in ids])
274
+ p._roi_manager.select_region(ids[0] if ids else None)
275
+ p._redraw_region_overlays()
276
+
277
+
278
+ def _capture(call, nam, mime):
279
+ fd, tmp = tempfile.mkstemp(suffix=Path(nam).suffix)
280
+ os.close(fd)
281
+ os.remove(tmp)
282
+ bridge.answer('asksaveasfilename', tmp)
283
+ call()
284
+ if os.path.exists(tmp) == False:
285
+ return {}
286
+ with open(tmp, encoding='utf-8') as f:
287
+ text = f.read()
288
+ os.remove(tmp)
289
+ return {'download': {'name': nam, 'mime': mime, 'text': text}}
290
+
291
+
292
+ def _ids(args):
293
+ return [int(i) for i in args.get('ids', [])]
294
+
295
+
296
+ def _need_idle(app):
297
+ if any(_btn_state(b) for b in (app._btn_fov, app._btn_st, app._btn_ph)) or bridge.progress_state():
298
+ raise ValueError('A task is already running.')
299
+
300
+
301
+ def fit_rows(result):
302
+ summary = result['summary']
303
+ taus = list(summary.get('taus_ns', []))
304
+ amps = list(summary.get('amps', []))
305
+ chi2 = summary.get('reduced_chi2_tail')
306
+ rows = [['τ' + str(i + 1), '%.4f' % t, 'ns'] for i, t in enumerate(taus)]
307
+ rows += [['A' + str(i + 1) + ' (amplitude)', '%.4f' % a, ''] for i, a in enumerate(amps)]
308
+ if taus and amps and np.sum(amps) > 0:
309
+ rows.append(['τ_mean (amplitude-weighted)', '%.4f' % float(np.dot(taus, amps) / np.sum(amps)), 'ns'])
310
+ if chi2 is not None:
311
+ rows.append(['χ²_r (tail)', '%.4f' % chi2, ''])
312
+ return {'name': result['region_name'], 'irf_source': result['irf_source'], 'rows': rows}
313
+
314
+
315
+ def fit_png(result):
316
+ from matplotlib.backends.backend_agg import FigureCanvasAgg
317
+ from matplotlib.figure import Figure
318
+ decay = np.asarray(result['decay'])
319
+ time_ns = np.asarray(result['time_ns'])
320
+ irf = result['irf_prompt']
321
+ model = result['summary'].get('model')
322
+ fig = Figure(figsize=(6.2, 4.2), facecolor='#2b2b2b')
323
+ FigureCanvasAgg(fig)
324
+ ax_d = fig.add_subplot(4, 1, (1, 3))
325
+ ax_r = fig.add_subplot(4, 1, 4, sharex=ax_d)
326
+ for ax in (ax_d, ax_r):
327
+ ax.set_facecolor('#1e1e1e')
328
+ ax.tick_params(colors='white', labelsize=8)
329
+ ax_d.semilogy(time_ns, decay, 'o-', color='steelblue', linewidth=1.2, markersize=2, label='Decay', alpha=0.8)
330
+ if irf is not None and np.max(irf) > 0:
331
+ irf = np.asarray(irf)
332
+ irf_sc = (irf / np.max(irf)) * decay.max() * 0.15
333
+ ax_d.semilogy(time_ns[:len(irf)], np.maximum(irf_sc, 1e-2), color='orange', linewidth=1.5,
334
+ label='IRF (' + str(result['irf_source']) + ')', alpha=0.7)
335
+ if model is not None and len(model) == len(decay):
336
+ model = np.asarray(model)
337
+ ax_d.semilogy(time_ns, model, color='red', linewidth=2.0, label='Fit', alpha=0.9)
338
+ with np.errstate(invalid='ignore', divide='ignore'):
339
+ resid = np.where(model > 0, (decay - model) / np.sqrt(model), 0.0)
340
+ ax_r.plot(time_ns, resid, color='steelblue', linewidth=0.9)
341
+ ax_r.axhline(0, color='red', linewidth=1.0, linestyle='--', alpha=0.7)
342
+ ax_d.legend(fontsize=7, loc='upper right', labelcolor='white', facecolor='#333333', edgecolor='#555555')
343
+ ax_d.set_title(str(result['region_name']), fontsize=9, color='white')
344
+ ax_r.set_xlabel('Time (ns)', color='white', fontsize=8)
345
+ fig.tight_layout(pad=0.8)
346
+ buf = io.BytesIO()
347
+ fig.savefig(buf, format='png', facecolor=fig.get_facecolor())
348
+ return buf.getvalue()
349
+
350
+
351
+ def fit_result(app, src, ids):
352
+ def get():
353
+ if src == 'phasor':
354
+ return app._phasor_panel._last_fit_result
355
+ return roi_panel(app)._last_fit_results.get(tuple(sorted(ids)))
356
+ result = bridge.on_ui(app, get)
357
+ if result is None:
358
+ raise ValueError('No fit result cached for this selection yet.')
359
+ return result
360
+
361
+
362
+ def result_image(app, i):
363
+ path = bridge.on_ui(app, lambda: Path(app._res._imgs[i]))
364
+ if path.suffix.lower() == '.png':
365
+ return path.read_bytes()
366
+ from PIL import Image
367
+ buf = io.BytesIO()
368
+ Image.open(path).convert('RGB').save(buf, format='PNG')
369
+ return buf.getvalue()
370
+
371
+
372
+ # ---------- Single FOV, display, results ----------
373
+
374
+ def act_run_fov(app, args):
375
+ _need_idle(app)
376
+ if app.sv_fov_analysis.get() == 'zstack':
377
+ if Path(app.sv_zstack_dir.get().strip() or '.missing').is_dir() == False:
378
+ raise ValueError('Please select a valid z-stack folder.')
379
+ else:
380
+ ptu = app.sv_ptu.get().strip()
381
+ if ptu == '' or Path(ptu).exists() == False:
382
+ raise ValueError('Please select a valid PTU file.')
383
+ app._run_fov()
384
+
385
+
386
+ def act_cancel(app, args):
387
+ bridge.cancel_all()
388
+
389
+
390
+ def act_switch_form(app, args):
391
+ form = str(args.get('form', 'fov'))
392
+ if form not in ('fov', 'stitch', 'phasor', 'batch', 'irf'):
393
+ raise ValueError('Unknown mode ' + form)
394
+ app._switch_form(form)
395
+
396
+
397
+ def act_auto_scale(app, args):
398
+ app._fov_preview._auto_detect_scale()
399
+
400
+
401
+ def act_update_display(app, args):
402
+ app._fov_preview._update_flim_display()
403
+
404
+
405
+ def act_z(app, args):
406
+ app._fov_preview._on_z_slider(int(args.get('i', 0)))
407
+
408
+
409
+ def act_clear_log(app, args):
410
+ app._res._clear_log()
411
+
412
+
413
+ def act_save_log(app, args):
414
+ text = app._res.log.get('1.0', 'end')
415
+ return {'download': {'name': _stem(app) + '_log.txt', 'mime': 'text/plain', 'text': text}}
416
+
417
+
418
+ def act_export_summary_csv(app, args):
419
+ tv = app._res._tv
420
+ rows = [tv.item(i, 'values') for i in tv.get_children()]
421
+ if not rows:
422
+ raise ValueError('No summary data to export.')
423
+ buf = io.StringIO()
424
+ writer = csv.writer(buf)
425
+ writer.writerow(['Parameter', 'Value', 'Unit'])
426
+ writer.writerows(rows)
427
+ return {'download': {'name': _stem(app) + '_summed_fit.csv', 'mime': 'text/csv', 'text': buf.getvalue()}}
428
+
429
+
430
+ def act_export_images(app, args):
431
+ fit = app._res._fit_result or {}
432
+ selected = {k: fit[k] for k in args.get('images', []) if k in fit}
433
+ if not selected:
434
+ raise ValueError('Please select at least one image to export.')
435
+ path = str(args.get('path', '') or '').strip() or str(app._res._output_dir or '')
436
+ if path == '':
437
+ raise ValueError('Please select an export directory.')
438
+ app._export_images(selected, path,
439
+ with_scalebar=args.get('scalebar', True) == True,
440
+ with_annotations=args.get('annotations', True) == True,
441
+ format=args.get('format', 'png'),
442
+ fit_result=fit)
443
+ return {'message': 'Results exported to ' + path}
444
+
445
+
446
+ def act_save_session(app, args):
447
+ folder = str(args.get('path', '')).strip()
448
+ if folder == '':
449
+ raise ValueError('Choose a folder to save the session into.')
450
+ bridge.answer('askdirectory', folder)
451
+ bridge.answer('askyesno', True)
452
+ app._save_npz_quick(str(app._res._output_dir or ''))
453
+
454
+
455
+ def act_save_npz_as(app, args):
456
+ path = str(args.get('path', '')).strip()
457
+ if path == '':
458
+ raise ValueError('Enter the .npz file path to save to.')
459
+ bridge.answer('asksaveasfilename', path)
460
+ app._menu_save_npz_as()
461
+
462
+
463
+ def act_load_session(app, args):
464
+ path = str(args.get('path', '')).strip()
465
+ if path == '' or Path(path).is_file() == False:
466
+ raise ValueError('Choose a .npz session file.')
467
+ app._load_fitted_data_from_file(path)
468
+
469
+
470
+ def act_menu_reset(app, args):
471
+ bridge.answer('askyesno', True)
472
+ app._menu_reset()
473
+
474
+
475
+ # ---------- ROIs ----------
476
+
477
+ def act_roi_mode(app, args):
478
+ roi_panel(app)._set_mode(str(args.get('mode', 'select')))
479
+
480
+
481
+ def act_roi_add(app, args):
482
+ tool = str(args.get('tool', ''))
483
+ if tool not in ('rect', 'ellipse', 'polygon', 'freehand'):
484
+ raise ValueError('Unknown ROI tool ' + tool)
485
+ p = app._fov_preview
486
+ p._draw_coords = [[float(x), float(y)] for x, y in args.get('coords', [])]
487
+ p._finalize_drawing(tool)
488
+
489
+
490
+ def act_roi_select(app, args):
491
+ _select(app, _ids(args))
492
+
493
+
494
+ def act_roi_delete(app, args):
495
+ p = app._fov_preview
496
+ for rid in _ids(args):
497
+ p._roi_manager.remove_region(rid)
498
+ p._redraw_region_overlays()
499
+ p._save_regions_update()
500
+ roi_panel(app)._refresh_region_list()
501
+
502
+
503
+ def act_roi_rename(app, args):
504
+ name = str(args.get('name', '')).strip()
505
+ if name == '':
506
+ raise ValueError('Enter a name.')
507
+ p = app._fov_preview
508
+ p._roi_manager.update_region(int(args['id']), name=name)
509
+ p._save_regions_update()
510
+ roi_panel(app)._refresh_region_list()
511
+
512
+
513
+ def act_roi_clear(app, args):
514
+ roi_panel(app)._clear_all_regions()
515
+
516
+
517
+ def act_roi_import(app, args):
518
+ path = str(args.get('path', '')).strip()
519
+ if path == '':
520
+ raise ValueError('Choose a GeoJSON file.')
521
+ bridge.answer('askopenfilename', path)
522
+ roi_panel(app)._import_rois_geojson()
523
+
524
+
525
+ def act_roi_export_csv(app, args):
526
+ return _capture(roi_panel(app)._export_all_rois_csv, _stem(app) + '_roi_data.csv', 'text/csv')
527
+
528
+
529
+ def act_roi_export_geojson(app, args):
530
+ panel = roi_panel(app)
531
+ ids = _ids(args)
532
+ if ids:
533
+ _select(app, ids[:1])
534
+ return _capture(panel._export_selected_region, _stem(app) + '_roi_' + str(ids[0]) + '.geojson', 'application/geo+json')
535
+ return _capture(panel._export_all_rois_geojson, _stem(app) + '_all_rois.geojson', 'application/geo+json')
536
+
537
+
538
+ def _fit_opts(args):
539
+ opts = {
540
+ 'n_exp': int(args.get('n_exp', 1)),
541
+ 'tau_min': float(args.get('tau_min', 0.1)),
542
+ 'tau_max': float(args.get('tau_max', 25.0)),
543
+ 'cost_function': str(args.get('cost_function', 'poisson')),
544
+ }
545
+ if opts['tau_min'] <= 0 or opts['tau_max'] <= opts['tau_min']:
546
+ raise ValueError('Need 0 < τ_min < τ_max.')
547
+ return opts
548
+
549
+
550
+ def act_roi_fit(app, args):
551
+ ids = _ids(args)
552
+ if not ids:
553
+ raise ValueError('Select a region in the list first.')
554
+ bridge.answer('roi_opts', _fit_opts(args))
555
+ _select(app, ids)
556
+ roi_panel(app)._fit_roi_decay()
557
+
558
+
559
+ def act_fit_rows(app, args):
560
+ getter = app._phasor_panel._last_fit_result if args.get('src') == 'phasor' \
561
+ else roi_panel(app)._last_fit_results.get(tuple(sorted(_ids(args))))
562
+ if getter is None:
563
+ raise ValueError('No fit result cached for this selection yet.')
564
+ return fit_rows(getter)
565
+
566
+
567
+ # ---------- expert settings ----------
568
+
569
+ def _expert_parse(raw):
570
+ from flimkit.interactive import parse_exclude_ns
571
+
572
+ def num(key, default, cast):
573
+ s = str(raw.get(key) if raw.get(key) is not None else '').strip()
574
+ return cast(s) if s != '' else default
575
+
576
+ ch = str(raw.get('channels', '') or '').strip()
577
+ excl = str(raw.get('exclude_ns', '') or '').strip()
578
+ parse_exclude_ns(excl or None)
579
+ return {
580
+ 'optimizer': str(raw.get('optimizer', 'de')),
581
+ 'de_population': num('de_population', 30, int),
582
+ 'de_maxiter': num('de_maxiter', 5000, int),
583
+ 'lm_restarts': num('lm_restarts', 8, int),
584
+ 'binning_factor': num('binning_factor', 1, int),
585
+ 'n_workers': num('n_workers', -1, int),
586
+ 'min_photons': num('min_photons', 10, int),
587
+ 'cost_function': str(raw.get('cost_function', 'poisson')),
588
+ 'channels': int(ch) if ch.isdigit() else (None if ch == '' else ch),
589
+ 'irf_fwhm': num('irf_fwhm', None, float),
590
+ 'irf_align': str(raw.get('irf_align', 'steepest_rise')),
591
+ 'irf_shift_bins': num('irf_shift_bins', 2, int),
592
+ 'align_irf': raw.get('align_irf') == True,
593
+ 'free_tau_perpixel': raw.get('free_tau_perpixel') == True,
594
+ 'pileup_in_model': raw.get('pileup_in_model') == True,
595
+ 'bg_in_model': raw.get('bg_in_model') == True,
596
+ 'fit_t0': raw.get('fit_t0') == True,
597
+ 'fit_start_ns': num('fit_start_ns', None, float),
598
+ 'fit_end_ns': num('fit_end_ns', None, float),
599
+ 'exclude_ns': excl,
600
+ }
601
+
602
+
603
+ def _expert_apply(app, result):
604
+ from flimkit.UI.expert_settings import _EXPERT_DEFAULTS
605
+ from flimkit.utils.config_manager import cfg
606
+ is_default = all(result.get(k) == v for k, v in _EXPERT_DEFAULTS.items())
607
+ app._expert_overrides = {} if is_default == True else result
608
+ cfg.update_section('expert', result)
609
+ browser = getattr(app, '_proj_browser', None)
610
+ if browser and browser._project:
611
+ browser._project.config['expert'] = result
612
+ browser._project.save()
613
+ cfg.load_project_overrides(browser._project.config)
614
+ app._update_expert_banners()
615
+
616
+
617
+ def act_expert_save(app, args):
618
+ _expert_apply(app, _expert_parse(args.get('values', {})))
619
+
620
+
621
+ def act_expert_reset(app, args):
622
+ from flimkit.UI.expert_settings import _EXPERT_DEFAULTS
623
+ _expert_apply(app, dict(_EXPERT_DEFAULTS))
624
+
625
+
626
+ ACTIONS = {
627
+ 'run_fov': act_run_fov,
628
+ 'cancel': act_cancel,
629
+ 'switch_form': act_switch_form,
630
+ 'auto_scale': act_auto_scale,
631
+ 'update_display': act_update_display,
632
+ 'z': act_z,
633
+ 'clear_log': act_clear_log,
634
+ 'save_log': act_save_log,
635
+ 'export_summary_csv': act_export_summary_csv,
636
+ 'export_images': act_export_images,
637
+ 'save_session': act_save_session,
638
+ 'save_npz_as': act_save_npz_as,
639
+ 'load_session': act_load_session,
640
+ 'menu_reset': act_menu_reset,
641
+ 'roi_mode': act_roi_mode,
642
+ 'roi_add': act_roi_add,
643
+ 'roi_select': act_roi_select,
644
+ 'roi_delete': act_roi_delete,
645
+ 'roi_rename': act_roi_rename,
646
+ 'roi_clear': act_roi_clear,
647
+ 'roi_import': act_roi_import,
648
+ 'roi_export_csv': act_roi_export_csv,
649
+ 'roi_export_geojson': act_roi_export_geojson,
650
+ 'roi_fit': act_roi_fit,
651
+ 'fit_rows': act_fit_rows,
652
+ 'expert_save': act_expert_save,
653
+ 'expert_reset': act_expert_reset,
654
+ }
655
+
656
+
657
+ def run_action(app, name, args):
658
+ from . import api_modes
659
+ bridge.mark_action()
660
+ off_ui = api_modes.OFF_UI.get(name)
661
+ fn = off_ui or ACTIONS.get(name) or api_modes.ACTIONS.get(name)
662
+ if fn is None:
663
+ raise ValueError('Unknown action ' + str(name))
664
+
665
+ def do():
666
+ try:
667
+ return fn(app, args) or {}
668
+ finally:
669
+ bridge.clear_answers()
670
+
671
+ try:
672
+ out = do() if off_ui is not None else bridge.on_ui(app, do)
673
+ except Exception:
674
+ bridge.clear_answers()
675
+ raise
676
+ out['notes'] = bridge.drain_notes()
677
+ return out