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,406 @@
1
+ import threading
2
+
3
+ API_VERSION = 1
4
+
5
+ _lock = threading.RLock()
6
+ _tools = {}
7
+ _formats = {}
8
+ _sniffers = []
9
+ _phasor_filters = {}
10
+ _panel_buttons = {}
11
+ _startups = {}
12
+ _version = 0
13
+ _current_source = None
14
+ MODALITIES = ('time', 'frequency', 'intensity')
15
+
16
+
17
+ class PluginError(Exception):
18
+ pass
19
+
20
+
21
+ class Tool:
22
+
23
+ def __init__(self, id, label, callback, menu, order, source):
24
+ self.id = id
25
+ self.label = label
26
+ self.callback = callback
27
+ self.menu = menu
28
+ self.order = order
29
+ self.source = source
30
+
31
+ @property
32
+ def menu_path(self):
33
+ return tuple(p.strip() for p in self.menu.split('/') if p.strip())
34
+
35
+ def __repr__(self):
36
+ return f'<Tool {self.id} menu={self.menu!r} source={self.source!r}>'
37
+
38
+
39
+ class PanelButton:
40
+
41
+ def __init__(self, id, label, callback, panel, order, source):
42
+ self.id = id
43
+ self.label = label
44
+ self.callback = callback
45
+ self.panel = panel
46
+ self.order = order
47
+ self.source = source
48
+
49
+ def __repr__(self):
50
+ return f'<PanelButton {self.id} panel={self.panel!r} source={self.source!r}>'
51
+
52
+
53
+ class Startup:
54
+
55
+ def __init__(self, id, callback, order, source):
56
+ self.id = id
57
+ self.callback = callback
58
+ self.order = order
59
+ self.source = source
60
+
61
+ def __repr__(self):
62
+ return f'<Startup {self.id} source={self.source!r}>'
63
+
64
+
65
+ class Format:
66
+
67
+ def __init__(self, id, label, exts, modality, reader, source):
68
+ self.id = id
69
+ self.label = label
70
+ self.exts = tuple(e.lower() for e in exts)
71
+ self.modality = modality
72
+ self._reader = reader
73
+ self.source = source
74
+
75
+ def reader(self):
76
+ if isinstance(self._reader, str):
77
+ import importlib
78
+ mod_name, _, cls_name = self._reader.partition(':')
79
+ if not cls_name:
80
+ raise PluginError(
81
+ f'format {self.id!r} reader {self._reader!r} is not module:Class')
82
+ return getattr(importlib.import_module(mod_name), cls_name)
83
+ return self._reader
84
+
85
+ def __repr__(self):
86
+ return f'<Format {self.id} exts={self.exts} source={self.source!r}>'
87
+
88
+
89
+ class Sniffer:
90
+
91
+ def __init__(self, fn, tier, order, source):
92
+ self.fn = fn
93
+ self.tier = tier
94
+ self.order = order
95
+ self.source = source
96
+
97
+ def __call__(self, path):
98
+ return self.fn(path)
99
+
100
+ def __repr__(self):
101
+ return f'<Sniffer tier={self.tier} order={self.order} source={self.source!r}>'
102
+
103
+
104
+ def _source():
105
+ return _current_source or '<direct>'
106
+
107
+
108
+ def _bump():
109
+ global _version
110
+ _version += 1
111
+
112
+
113
+ def version():
114
+ return _version
115
+
116
+
117
+ def count():
118
+ with _lock:
119
+ return (len(_tools) + len(_formats) + len(_sniffers)
120
+ + len(_phasor_filters) + len(_panel_buttons) + len(_startups))
121
+
122
+
123
+ def register_tool(id, label, callback, menu='Tools', order=100, source=None):
124
+ if not id or not isinstance(id, str):
125
+ raise PluginError(f'tool id must be a non-empty string, got {id!r}')
126
+ if not callable(callback):
127
+ raise PluginError(f'tool {id!r} callback is not callable: {callback!r}')
128
+ with _lock:
129
+ existing = _tools.get(id)
130
+ if existing is not None:
131
+ raise PluginError(
132
+ f'tool id {id!r} already registered by {existing.source!r}, '
133
+ f'refused from {source or _source()!r}')
134
+ _tools[id] = Tool(id, label, callback, menu, order, source or _source())
135
+ _bump()
136
+ return _tools[id]
137
+
138
+
139
+ def tool(id, label, menu='Tools', order=100):
140
+ def decorate(fn):
141
+ register_tool(id, label, fn, menu=menu, order=order)
142
+ return fn
143
+ return decorate
144
+
145
+
146
+ def tools(menu=None):
147
+ with _lock:
148
+ found = list(_tools.values())
149
+ if menu is not None:
150
+ want = tuple(p.strip() for p in menu.split('/') if p.strip())
151
+ found = [t for t in found if t.menu_path[:len(want)] == want]
152
+ found.sort(key=lambda t: (t.order, t.label, t.id))
153
+ return found
154
+
155
+
156
+ def get_tool(id):
157
+ with _lock:
158
+ return _tools.get(id)
159
+
160
+
161
+ PANELS = ('roi',)
162
+
163
+
164
+ def register_panel_button(id, label, callback, panel='roi', order=100, source=None):
165
+ if not id or not isinstance(id, str):
166
+ raise PluginError(f'panel button id must be a non-empty string, got {id!r}')
167
+ if not callable(callback):
168
+ raise PluginError(f'panel button {id!r} callback is not callable: {callback!r}')
169
+ if panel not in PANELS:
170
+ raise PluginError(f'unknown panel {panel!r}, expected one of {PANELS}')
171
+ with _lock:
172
+ existing = _panel_buttons.get(id)
173
+ if existing is not None:
174
+ raise PluginError(
175
+ f'panel button id {id!r} already registered by {existing.source!r}, '
176
+ f'refused from {source or _source()!r}')
177
+ _panel_buttons[id] = PanelButton(
178
+ id, label, callback, panel, order, source or _source())
179
+ _bump()
180
+ return _panel_buttons[id]
181
+
182
+
183
+ def panel_button(id, label, panel='roi', order=100):
184
+ def decorate(fn):
185
+ register_panel_button(id, label, fn, panel=panel, order=order)
186
+ return fn
187
+ return decorate
188
+
189
+
190
+ def panel_buttons(panel=None):
191
+ with _lock:
192
+ found = list(_panel_buttons.values())
193
+ if panel is not None:
194
+ found = [b for b in found if b.panel == panel]
195
+ found.sort(key=lambda b: (b.order, b.label, b.id))
196
+ return found
197
+
198
+
199
+ def get_panel_button(id):
200
+ with _lock:
201
+ return _panel_buttons.get(id)
202
+
203
+
204
+ def register_startup(id, callback, order=100, source=None):
205
+ if not id or not isinstance(id, str):
206
+ raise PluginError(f'startup id must be a non-empty string, got {id!r}')
207
+ if not callable(callback):
208
+ raise PluginError(f'startup {id!r} callback is not callable: {callback!r}')
209
+ with _lock:
210
+ existing = _startups.get(id)
211
+ if existing is not None:
212
+ raise PluginError(
213
+ f'startup id {id!r} already registered by {existing.source!r}, '
214
+ f'refused from {source or _source()!r}')
215
+ _startups[id] = Startup(id, callback, order, source or _source())
216
+ _bump()
217
+ return _startups[id]
218
+
219
+
220
+ def startup(id, order=100):
221
+ def decorate(fn):
222
+ register_startup(id, fn, order=order)
223
+ return fn
224
+ return decorate
225
+
226
+
227
+ def startups():
228
+ with _lock:
229
+ found = list(_startups.values())
230
+ found.sort(key=lambda s: (s.order, s.id))
231
+ return found
232
+
233
+
234
+ def run_startups(app):
235
+ failures = []
236
+ for entry in startups():
237
+ try:
238
+ entry.callback(app)
239
+ except Exception as exc:
240
+ failures.append((entry, exc))
241
+ return failures
242
+
243
+
244
+ def register_format(id, label, exts=(), modality='time', reader=None, source=None):
245
+ if not id or not isinstance(id, str):
246
+ raise PluginError(f'format id must be a non-empty string, got {id!r}')
247
+ if modality not in MODALITIES:
248
+ raise PluginError(
249
+ f'format {id!r} modality {modality!r} is not one of {MODALITIES}')
250
+ if reader is None:
251
+ raise PluginError(f'format {id!r} has no reader')
252
+ if isinstance(exts, str):
253
+ exts = (exts,)
254
+ with _lock:
255
+ existing = _formats.get(id)
256
+ if existing is not None:
257
+ raise PluginError(
258
+ f'format id {id!r} already registered by {existing.source!r}, '
259
+ f'refused from {source or _source()!r}')
260
+ _formats[id] = Format(id, label, exts, modality, reader, source or _source())
261
+ _bump()
262
+ return _formats[id]
263
+
264
+
265
+ def file_format(id, label, exts=(), modality='time', reader=None):
266
+ def decorate(obj):
267
+ register_format(id, label, exts=exts, modality=modality,
268
+ reader=reader if reader is not None else obj)
269
+ return obj
270
+ return decorate
271
+
272
+
273
+ def formats():
274
+ with _lock:
275
+ return sorted(_formats.values(), key=lambda f: f.id)
276
+
277
+
278
+ def get_format(id):
279
+ with _lock:
280
+ return _formats.get(id)
281
+
282
+
283
+ def register_sniffer(fn, tier='magic', order=100, source=None):
284
+ if not callable(fn):
285
+ raise PluginError(f'sniffer is not callable: {fn!r}')
286
+ if tier not in ('extension', 'magic'):
287
+ raise PluginError(f'sniffer tier {tier!r} is not extension or magic')
288
+ with _lock:
289
+ found = Sniffer(fn, tier, order, source or _source())
290
+ _sniffers.append(found)
291
+ _bump()
292
+ return found
293
+
294
+
295
+ def format_sniffer(tier='magic', order=100):
296
+ def decorate(fn):
297
+ register_sniffer(fn, tier=tier, order=order)
298
+ return fn
299
+ return decorate
300
+
301
+
302
+ def sniffers(tier=None):
303
+ with _lock:
304
+ found = list(_sniffers)
305
+ if tier is not None:
306
+ found = [s for s in found if s.tier == tier]
307
+ found.sort(key=lambda s: s.order)
308
+ return found
309
+
310
+
311
+ class PhasorFilter:
312
+
313
+ def __init__(self, id, label, fn, source):
314
+ self.id = id
315
+ self.label = label
316
+ self.fn = fn
317
+ self.source = source
318
+
319
+ def __call__(self, real, imag, **kwargs):
320
+ import inspect
321
+ params = inspect.signature(self.fn).parameters
322
+ if any(p.kind == inspect.Parameter.VAR_KEYWORD for p in params.values()):
323
+ allowed = kwargs
324
+ else:
325
+ allowed = {k: v for k, v in kwargs.items() if k in params}
326
+ return self.fn(real, imag, **allowed)
327
+
328
+ def __repr__(self):
329
+ return f'<PhasorFilter {self.id} source={self.source!r}>'
330
+
331
+
332
+ def register_phasor_filter(id, label, fn, source=None):
333
+ if not id or not isinstance(id, str):
334
+ raise PluginError(f'phasor filter id must be a non-empty string, got {id!r}')
335
+ if not callable(fn):
336
+ raise PluginError(f'phasor filter {id!r} is not callable: {fn!r}')
337
+ id = id.lower()
338
+ with _lock:
339
+ existing = _phasor_filters.get(id)
340
+ if existing is not None:
341
+ raise PluginError(
342
+ f'phasor filter id {id!r} already registered by {existing.source!r}, '
343
+ f'refused from {source or _source()!r}')
344
+ _phasor_filters[id] = PhasorFilter(id, label, fn, source or _source())
345
+ _bump()
346
+ return _phasor_filters[id]
347
+
348
+
349
+ def phasor_filter(id, label):
350
+ def decorate(fn):
351
+ register_phasor_filter(id, label, fn)
352
+ return fn
353
+ return decorate
354
+
355
+
356
+ def phasor_filters():
357
+ with _lock:
358
+ return sorted(_phasor_filters.values(), key=lambda f: f.id)
359
+
360
+
361
+ def get_phasor_filter(id):
362
+ with _lock:
363
+ return _phasor_filters.get(str(id).lower())
364
+
365
+
366
+ def sources():
367
+ with _lock:
368
+ every = (list(_tools.values()) + list(_formats.values()) + list(_sniffers)
369
+ + list(_phasor_filters.values()))
370
+ return sorted({t.source for t in every})
371
+
372
+
373
+ def _set_source(name):
374
+ global _current_source
375
+ prev = _current_source
376
+ _current_source = name
377
+ return prev
378
+
379
+
380
+ def _rollback(source):
381
+ with _lock:
382
+ dropped = [k for k, t in _tools.items() if t.source == source]
383
+ for k in dropped:
384
+ del _tools[k]
385
+ dropped += [k for k, f in _formats.items() if f.source == source]
386
+ for k in [k for k, f in _formats.items() if f.source == source]:
387
+ del _formats[k]
388
+ keep = [s for s in _sniffers if s.source != source]
389
+ dropped += ['sniffer'] * (len(_sniffers) - len(keep))
390
+ _sniffers[:] = keep
391
+ dropped += [k for k, f in _phasor_filters.items() if f.source == source]
392
+ for k in [k for k, f in _phasor_filters.items() if f.source == source]:
393
+ del _phasor_filters[k]
394
+ _bump()
395
+ return dropped
396
+
397
+
398
+ def clear():
399
+ with _lock:
400
+ _tools.clear()
401
+ _formats.clear()
402
+ _sniffers.clear()
403
+ _phasor_filters.clear()
404
+ _panel_buttons.clear()
405
+ _startups.clear()
406
+ _bump()
flimkit/project.py ADDED
@@ -0,0 +1,197 @@
1
+ import json
2
+ from dataclasses import dataclass, asdict
3
+ from pathlib import Path
4
+
5
+ _SPTW_CANDIDATES = [
6
+ '{stem}.sptw',
7
+ 'PTU.sptw',
8
+ 'FLIM.sptw',
9
+ ]
10
+
11
+ PROJECT_FILENAME = 'project.json'
12
+ DEFAULT_OUTPUT_SUBDIR = 'output'
13
+
14
+ @dataclass
15
+ class ScanRecord:
16
+ stem: str
17
+ scan_type: str
18
+ source_path: str
19
+ ptu_dir: str | None = None
20
+ out_st: str | None = None
21
+ output_prefix: str | None = None
22
+ xlsx_path: str | None = None
23
+
24
+ @property
25
+ def roi_clean(self):
26
+ return self.stem.replace(' ', '_')
27
+
28
+ @property
29
+ def session_path(self):
30
+ if self.scan_type == 'fov':
31
+ p = Path(self.source_path)
32
+ candidate = p.parent / f'{p.stem}.roi_session.npz'
33
+ elif self.scan_type == 'zstack':
34
+ if not self.out_st:
35
+ return None
36
+ candidate = Path(self.out_st) / self.stem / f'{self.stem}_reference_fit.json'
37
+ else:
38
+ if not self.out_st:
39
+ return None
40
+ candidate = Path(self.out_st) / self.roi_clean / 'roi_session.npz'
41
+ return candidate if candidate.exists() else None
42
+
43
+ @property
44
+ def phasor_session_path(self):
45
+ if self.scan_type != 'fov':
46
+ return None
47
+ p = Path(self.source_path)
48
+ candidate = p.parent / f'{p.stem}_phasor.npz'
49
+ return candidate if candidate.exists() else None
50
+
51
+ @property
52
+ def has_session(self):
53
+ return self.session_path is not None
54
+
55
+ @property
56
+ def has_phasor_session(self):
57
+ return self.phasor_session_path is not None
58
+
59
+ class ProjectFile:
60
+
61
+ def __init__(self, project_dir):
62
+ self.project_dir = Path(project_dir).resolve()
63
+ self.output_base = self.project_dir / DEFAULT_OUTPUT_SUBDIR
64
+ self.scans = {}
65
+ self.config = {}
66
+
67
+ @classmethod
68
+ def load_or_create(cls, project_dir):
69
+ pf = cls(project_dir)
70
+ json_path = pf.project_dir / PROJECT_FILENAME
71
+ if json_path.exists():
72
+ try:
73
+ with open(json_path, encoding='utf-8') as fh:
74
+ data = json.load(fh)
75
+ ob = data.get('output_base')
76
+ if ob:
77
+ pf.output_base = Path(ob)
78
+ for stem, rec_dict in data.get('scans', {}).items():
79
+ pf.scans[stem] = ScanRecord(**rec_dict)
80
+ pf.config = data.get('config', {})
81
+ except Exception as exc:
82
+ print(f'[Project] Warning: could not read {json_path.name}: {exc}')
83
+ pf._scan_folder()
84
+ return pf
85
+
86
+ def save(self):
87
+ self.project_dir.mkdir(parents=True, exist_ok=True)
88
+ payload = {
89
+ 'output_base': str(self.output_base),
90
+ 'scans': {stem: asdict(rec) for stem, rec in self.scans.items()},
91
+ }
92
+ if self.config:
93
+ payload['config'] = self.config
94
+ with open(self.project_dir / PROJECT_FILENAME, 'w', encoding='utf-8') as fh:
95
+ json.dump(payload, fh, indent=2, ensure_ascii=False)
96
+
97
+ def _scan_folder(self):
98
+ from flimkit.formats import supported_extensions
99
+ zstack_groups = {}
100
+ zstack_slice_paths = set()
101
+ try:
102
+ from flimkit.utils.batch_fit import group_zstack_files
103
+ for (region, t, s), zslices in group_zstack_files(self.project_dir).items():
104
+ if t == 0 and s == 0 and len(zslices) >= 2:
105
+ zstack_groups[region] = zslices
106
+ zstack_slice_paths.update(str(p) for p in zslices.values())
107
+ except Exception:
108
+ pass
109
+ _globs = tuple('*' + e for e in supported_extensions())
110
+ flim_files = sorted(p for ext in _globs
111
+ for p in self.project_dir.glob(ext))
112
+ for ptu in flim_files:
113
+ if ptu.name.startswith('._'):
114
+ continue
115
+ if str(ptu) in zstack_slice_paths:
116
+ continue
117
+ if ptu.stem not in self.scans:
118
+ xlsx_file = self.project_dir / f'{ptu.stem}.xlsx'
119
+ xlsx_path = str(xlsx_file) if xlsx_file.exists() and not xlsx_file.name.startswith('._') else None
120
+ self.scans[ptu.stem] = ScanRecord(
121
+ stem=ptu.stem,
122
+ scan_type='fov',
123
+ source_path=str(ptu),
124
+ ptu_dir=None,
125
+ out_st=None,
126
+ output_prefix=None,
127
+ xlsx_path=xlsx_path,
128
+ )
129
+ for region in zstack_groups:
130
+ if region not in self.scans:
131
+ self.scans[region] = ScanRecord(
132
+ stem=region,
133
+ scan_type='zstack',
134
+ source_path=str(self.project_dir),
135
+ ptu_dir=str(self.project_dir),
136
+ out_st=str(self.output_base),
137
+ output_prefix=None,
138
+ xlsx_path=None,
139
+ )
140
+ for xlif in sorted(self.project_dir.glob('*.xlif')):
141
+ if xlif.name.startswith('._'):
142
+ continue
143
+ if xlif.stem not in self.scans:
144
+ ptu_dir = self._find_sptw(xlif)
145
+ self.scans[xlif.stem] = ScanRecord(
146
+ stem=xlif.stem,
147
+ scan_type='xlif',
148
+ source_path=str(xlif),
149
+ ptu_dir=str(ptu_dir) if ptu_dir else None,
150
+ out_st=str(self.output_base),
151
+ output_prefix=None,
152
+ xlsx_path=None,
153
+ )
154
+
155
+ def _find_sptw(self, xlif):
156
+ base = xlif.parent
157
+ for template in _SPTW_CANDIDATES:
158
+ candidate = base / template.format(stem=xlif.stem)
159
+ if candidate.is_dir():
160
+ return candidate
161
+ return None
162
+
163
+ def update_after_fit(
164
+ self,
165
+ stem,
166
+ *,
167
+ out_st=None,
168
+ output_prefix=None,
169
+ ptu_dir=None,
170
+ ):
171
+ rec = self.scans.get(stem)
172
+ if rec is None:
173
+ return
174
+ if out_st is not None:
175
+ rec.out_st = out_st
176
+ if output_prefix is not None:
177
+ rec.output_prefix = output_prefix
178
+ if ptu_dir is not None:
179
+ rec.ptu_dir = ptu_dir
180
+
181
+ def update_after_phasor(self, stem: str):
182
+ pass
183
+
184
+ def default_out_st(self, stem):
185
+ rec = self.scans.get(stem)
186
+ if rec and rec.out_st:
187
+ return rec.out_st
188
+ return str(self.output_base)
189
+
190
+ def default_output_prefix(self, stem):
191
+ rec = self.scans.get(stem)
192
+ if rec and rec.output_prefix:
193
+ return rec.output_prefix
194
+ return stem
195
+
196
+ def sorted_scans(self):
197
+ yield from sorted(self.scans.items())