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,1182 @@
1
+ import json
2
+ import numpy as np
3
+ import tifffile
4
+ from pathlib import Path
5
+ from tqdm import tqdm
6
+
7
+ tqdm.disable = True
8
+
9
+ from ...utils.xml_utils import (
10
+ parse_tile_positions,
11
+ get_pixel_size,
12
+ compute_tile_pixel_positions,
13
+ )
14
+ from .decode import get_flim_histogram_from_ptufile, create_time_axis
15
+
16
+ def stitch_flim_tiles(
17
+ xlif_path,
18
+ ptu_dir,
19
+ output_dir,
20
+ ptu_basename='R 2',
21
+ rotate_tiles=True,
22
+ register_tiles=True,
23
+ reg_max_shift_px=120,
24
+ tile_positions=None,
25
+ verbose=True,
26
+ progress_callback=None,
27
+ cancel_event=None,
28
+ ):
29
+ xlif_path = Path(xlif_path)
30
+ ptu_dir = Path(ptu_dir)
31
+ output_dir = Path(output_dir)
32
+ output_dir.mkdir(exist_ok=True, parents=True)
33
+ roi_prefix = ptu_basename.replace(' ', '_')
34
+ output_intensity = output_dir / f"{roi_prefix}_stitched_intensity.tif"
35
+ output_flim = output_dir / f"{roi_prefix}_stitched_flim_counts.npy"
36
+ output_time = output_dir / f"{roi_prefix}_time_axis_ns.npy"
37
+ output_weight = output_dir / f"{roi_prefix}_weight_map.npy"
38
+ output_meta = output_dir / f"{roi_prefix}_metadata.json"
39
+ if verbose:
40
+ print(f"{'='*60}")
41
+ print(f"FLIM TILE STITCHING")
42
+ print(f"{'='*60}")
43
+ print(f"Metadata: {xlif_path}")
44
+ print(f"PTUs: {ptu_dir}")
45
+ print(f"Output: {output_dir}")
46
+ print()
47
+ print('Parsing tile metadata...')
48
+ if tile_positions is None:
49
+ tile_positions = parse_tile_positions(xlif_path, ptu_basename)
50
+ pixel_size_m, n_pixels = get_pixel_size(xlif_path, ptu_basename)
51
+ if verbose:
52
+ print(f" Found {len(tile_positions)} tiles")
53
+ print(f" Pixel size: {pixel_size_m * 1e6:.4f} µm")
54
+ first_tile_path = ptu_dir / tile_positions[0]['file']
55
+ if not first_tile_path.exists():
56
+ raise FileNotFoundError(f"First tile not found: {first_tile_path}")
57
+ if verbose:
58
+ print(f" Loading first tile: {first_tile_path.name}")
59
+ first_hist, first_meta = get_flim_histogram_from_ptufile(
60
+ first_tile_path, rotate_cw=rotate_tiles, binning=1, channel=None)
61
+ tile_y, tile_x = first_meta['tile_shape']
62
+ n_time_bins = first_meta['n_time_bins']
63
+ tcspc_resolution = first_meta['tcspc_resolution']
64
+ time_axis_ns = create_time_axis(n_time_bins, tcspc_resolution)
65
+ if verbose:
66
+ print(f" Tile shape: ({tile_y}, {tile_x}, {n_time_bins})")
67
+ print(f" TCSPC: {tcspc_resolution * 1e12:.2f} ps/bin")
68
+ print(f" Time range: 0 - {time_axis_ns[-1]:.2f} ns")
69
+ _positions_precomputed = ('pixel_x' in tile_positions[0] and
70
+ 'pixel_y' in tile_positions[0])
71
+ if not _positions_precomputed:
72
+ tile_positions, canvas_width, canvas_height = compute_tile_pixel_positions(
73
+ tile_positions, pixel_size_m, tile_x)
74
+ else:
75
+ canvas_width = max(t['pixel_x'] for t in tile_positions) + tile_x
76
+ canvas_height = max(t['pixel_y'] for t in tile_positions) + tile_y
77
+ if verbose:
78
+ print(f" Canvas: {canvas_height} × {canvas_width} pixels")
79
+ print()
80
+ print('Allocating arrays...')
81
+ intensity_canvas = np.zeros((canvas_height, canvas_width), dtype=np.float64)
82
+ flim_canvas = np.memmap(
83
+ str(output_flim), dtype=np.uint32, mode='w+',
84
+ shape=(canvas_height, canvas_width, n_time_bins))
85
+ _owner = np.full((canvas_height, canvas_width), -1, dtype=np.int32)
86
+ _min_dist2 = np.full((canvas_height, canvas_width), np.inf, dtype=np.float64)
87
+ _hists = []
88
+ if verbose:
89
+ print(f"Stitching {len(tile_positions)} tiles...")
90
+ print()
91
+ tiles_processed = tiles_skipped = 0
92
+ total_tiles = len(tile_positions)
93
+ tile_results = []
94
+ for i, t in enumerate(tqdm(tile_positions, desc=' Loading tiles', disable=True)):
95
+ if cancel_event is not None and cancel_event.is_set():
96
+ if verbose:
97
+ print('\nStitching cancelled by user.')
98
+ break
99
+ if progress_callback is not None:
100
+ progress_callback(i, total_tiles)
101
+ tile_path = ptu_dir / t['file']
102
+ if not tile_path.exists():
103
+ if verbose:
104
+ print(f" [{i+1:3d}/{len(tile_positions)}] MISSING: {t['file']}")
105
+ tiles_skipped += 1
106
+ continue
107
+ try:
108
+ hist, meta = get_flim_histogram_from_ptufile(
109
+ tile_path, rotate_cw=rotate_tiles, binning=1, channel=None)
110
+ if hist.shape[2] != n_time_bins:
111
+ if hist.shape[2] < n_time_bins:
112
+ padded = np.zeros(
113
+ (hist.shape[0], hist.shape[1], n_time_bins), dtype=hist.dtype)
114
+ padded[:, :, :hist.shape[2]] = hist
115
+ hist = padded
116
+ else:
117
+ hist = hist[:, :, :n_time_bins]
118
+ y0, x0 = t['pixel_y'], t['pixel_x']
119
+ y1 = min(y0 + tile_y, canvas_height)
120
+ x1 = min(x0 + tile_x, canvas_width)
121
+ dy, dx = y1 - y0, x1 - x0
122
+ ti = len(_hists)
123
+ _hists.append((ti, y0, x0, hist[:dy, :dx, :]))
124
+ intensity_map = hist[:dy, :dx, :].sum(axis=2).astype(np.float32)
125
+ tile_results.append({
126
+ 'pixel_maps': {'intensity': intensity_map},
127
+ 'pixel_y': y0,
128
+ 'pixel_x': x0,
129
+ 'tile_h': dy,
130
+ 'tile_w': dx,
131
+ 'ptu_name': t['file'],
132
+ })
133
+ cy = y0 + tile_y / 2.0
134
+ cx = x0 + tile_x / 2.0
135
+ rows = np.arange(y0, y1, dtype=np.float64)
136
+ cols = np.arange(x0, x1, dtype=np.float64)
137
+ dist2 = (rows - cy)[:, np.newaxis] ** 2 + (cols - cx) ** 2
138
+ region = _min_dist2[y0:y1, x0:x1]
139
+ closer = dist2 < region
140
+ _min_dist2[y0:y1, x0:x1] = np.where(closer, dist2, region)
141
+ _owner[y0:y1, x0:x1] = np.where(closer, ti, _owner[y0:y1, x0:x1])
142
+ tiles_processed += 1
143
+ except Exception as e:
144
+ if verbose:
145
+ print(f" [{i+1:3d}/{len(tile_positions)}] ERROR: {t['file']}: {e}")
146
+ tiles_skipped += 1
147
+ continue
148
+ if register_tiles and tiles_processed > 1 and tile_results:
149
+ if verbose:
150
+ print(f"\nRunning tile registration (phase correlation)...")
151
+ tile_results = _register_tile_columns(
152
+ tile_results,
153
+ max_shift_px=reg_max_shift_px,
154
+ verbose=verbose,
155
+ )
156
+ for i, tr in enumerate(tile_results):
157
+ if i < len(tile_positions):
158
+ tile_positions[i]['pixel_y'] = tr['pixel_y']
159
+ tile_positions[i]['pixel_x'] = tr['pixel_x']
160
+ _owner[:] = -1
161
+ _min_dist2[:] = np.inf
162
+ for ti, (hist_ti, y0_old, x0_old, h) in enumerate(_hists):
163
+ y0 = tile_positions[ti]['pixel_y'] if ti < len(tile_positions) else y0_old
164
+ x0 = tile_positions[ti]['pixel_x'] if ti < len(tile_positions) else x0_old
165
+ y1 = min(y0 + h.shape[0], canvas_height)
166
+ x1 = min(x0 + h.shape[1], canvas_width)
167
+ dy, dx = y1 - y0, x1 - x0
168
+ if dy <= 0 or dx <= 0:
169
+ continue
170
+ cy = y0 + h.shape[0] / 2.0
171
+ cx = x0 + h.shape[1] / 2.0
172
+ rows = np.arange(y0, y1, dtype=np.float64)
173
+ cols = np.arange(x0, x1, dtype=np.float64)
174
+ dist2 = (rows - cy)[:, np.newaxis] ** 2 + (cols - cx) ** 2
175
+ region = _min_dist2[y0:y1, x0:x1]
176
+ closer = dist2 < region
177
+ _min_dist2[y0:y1, x0:x1] = np.where(closer, dist2, region)
178
+ _owner[y0:y1, x0:x1] = np.where(closer, ti, _owner[y0:y1, x0:x1])
179
+ _hists[ti] = (hist_ti, y0, x0, h)
180
+ new_canvas_height = max(y0_ + h_.shape[0] for _, y0_, x0_, h_ in _hists)
181
+ new_canvas_width = max(x0_ + h_.shape[1] for _, y0_, x0_, h_ in _hists)
182
+ if new_canvas_height > canvas_height or new_canvas_width > canvas_width:
183
+ if verbose:
184
+ print(
185
+ f" Registration expanded canvas: "
186
+ f"{canvas_height}×{canvas_width} → "
187
+ f"{new_canvas_height}×{new_canvas_width} px"
188
+ )
189
+ intensity_canvas = np.zeros(
190
+ (new_canvas_height, new_canvas_width), dtype=np.float64)
191
+ flim_canvas._mmap.close()
192
+ flim_canvas = np.memmap(
193
+ str(output_flim), dtype=np.uint32, mode='w+',
194
+ shape=(new_canvas_height, new_canvas_width, n_time_bins))
195
+ _owner = np.full(
196
+ (new_canvas_height, new_canvas_width), -1, dtype=np.int32)
197
+ _min_dist2 = np.full(
198
+ (new_canvas_height, new_canvas_width), np.inf, dtype=np.float64)
199
+ for ti_, (_, y0_, x0_, h_) in enumerate(_hists):
200
+ y1_ = min(y0_ + h_.shape[0], new_canvas_height)
201
+ x1_ = min(x0_ + h_.shape[1], new_canvas_width)
202
+ cy_ = y0_ + h_.shape[0] / 2.0
203
+ cx_ = x0_ + h_.shape[1] / 2.0
204
+ rows_ = np.arange(y0_, y1_, dtype=np.float64)
205
+ cols_ = np.arange(x0_, x1_, dtype=np.float64)
206
+ d2_ = (rows_ - cy_)[:, np.newaxis] ** 2 + (cols_ - cx_) ** 2
207
+ reg_ = _min_dist2[y0_:y1_, x0_:x1_]
208
+ cl_ = d2_ < reg_
209
+ _min_dist2[y0_:y1_, x0_:x1_] = np.where(cl_, d2_, reg_)
210
+ _owner[y0_:y1_, x0_:x1_] = np.where(cl_, ti_, _owner[y0_:y1_, x0_:x1_])
211
+ canvas_height = new_canvas_height
212
+ canvas_width = new_canvas_width
213
+ if verbose:
214
+ blending_mode = 'with registration' if (register_tiles and tiles_processed > 1) else 'no blending'
215
+ print(f" Writing canvas (nearest-centre, {blending_mode})...")
216
+ for ti, y0, x0, h in _hists:
217
+ y1 = y0 + h.shape[0]
218
+ x1 = x0 + h.shape[1]
219
+ owned_r, owned_c = np.where(_owner[y0:y1, x0:x1] == ti)
220
+ if owned_r.size > 0:
221
+ flim_canvas[y0 + owned_r, x0 + owned_c, :] = h[owned_r, owned_c, :]
222
+ intensity_canvas[y0 + owned_r, x0 + owned_c] = \
223
+ h[owned_r, owned_c, :].sum(axis=1).astype(np.float64)
224
+ del _min_dist2
225
+ n_covered = int((_owner >= 0).sum())
226
+ if verbose:
227
+ print(f" {n_covered:,} pixels covered "
228
+ f"({100*n_covered/(canvas_height*canvas_width):.1f}% of canvas) "
229
+ f"nearest-centre selection, no blending")
230
+ print('Saving outputs...')
231
+ max_val = intensity_canvas.max()
232
+ intensity_scaled = (
233
+ (intensity_canvas / max_val * 65535).astype(np.uint16)
234
+ if max_val > 0 else
235
+ np.zeros_like(intensity_canvas, dtype=np.uint16)
236
+ )
237
+ tifffile.imwrite(str(output_intensity), intensity_scaled)
238
+ np.save(str(output_time), time_axis_ns)
239
+ np.save(str(output_weight), (_owner + 1).astype(np.uint16))
240
+ flim_canvas.flush()
241
+ flim_canvas._mmap.close()
242
+ del flim_canvas
243
+ metadata = {
244
+ 'canvas_shape': (canvas_height, canvas_width),
245
+ 'n_time_bins': int(n_time_bins),
246
+ 'time_range_ns': (0.0, float(time_axis_ns[-1])),
247
+ 'tcspc_resolution_ps': float(tcspc_resolution * 1e12),
248
+ 'pixel_size_um': float(pixel_size_m * 1e6),
249
+ 'tiles_processed': tiles_processed,
250
+ 'tiles_skipped': tiles_skipped,
251
+ 'ptu_basename': ptu_basename,
252
+ }
253
+ with open(output_meta, 'w') as f:
254
+ json.dump(metadata, f, indent=2)
255
+ if verbose:
256
+ for name in (output_intensity, output_flim, output_time,
257
+ output_weight, output_meta):
258
+ print(f" {name.name}")
259
+ print()
260
+ print(f"{'='*60}")
261
+ print(f"STITCHING COMPLETE")
262
+ print(f"{'='*60}")
263
+ print(f"Processed: {tiles_processed}/{len(tile_positions)} tiles")
264
+ print(f"Canvas: {canvas_height} × {canvas_width} × {n_time_bins}")
265
+ print(f"Time: 0 - {time_axis_ns[-1]:.2f} ns")
266
+ return {
267
+ 'intensity_path': output_intensity,
268
+ 'flim_path': output_flim,
269
+ 'time_axis_path': output_time,
270
+ 'weight_map_path': output_weight,
271
+ 'metadata_path': output_meta,
272
+ 'canvas_shape': (canvas_height, canvas_width),
273
+ 'n_time_bins': n_time_bins,
274
+ 'tiles_processed': tiles_processed,
275
+ 'tiles_skipped': tiles_skipped,
276
+ }
277
+
278
+ def load_stitched_flim(
279
+ output_dir,
280
+ mode='r',
281
+ ):
282
+ output_dir = Path(output_dir)
283
+ meta_candidates = sorted(output_dir.glob('*_metadata.json'))
284
+ if meta_candidates:
285
+ meta_path = meta_candidates[0]
286
+ roi_prefix = meta_path.name.replace('_metadata.json', '')
287
+ elif (output_dir / 'metadata.json').exists():
288
+ meta_path = output_dir / 'metadata.json'
289
+ roi_prefix = None
290
+ else:
291
+ raise FileNotFoundError(f"No metadata.json found in {output_dir}")
292
+ with open(meta_path, 'r') as f:
293
+ metadata = json.load(f)
294
+ canvas_shape = tuple(metadata['canvas_shape'])
295
+ n_time_bins = metadata['n_time_bins']
296
+ def _find(prefixed, generic):
297
+ p = output_dir / prefixed
298
+ return p if p.exists() else output_dir / generic
299
+ if roi_prefix:
300
+ time_path = _find(f"{roi_prefix}_time_axis_ns.npy", 'time_axis_ns.npy')
301
+ int_path = _find(f"{roi_prefix}_stitched_intensity.tif", 'stitched_intensity.tif')
302
+ flim_path = _find(f"{roi_prefix}_stitched_flim_counts.npy",'stitched_flim_counts.npy')
303
+ else:
304
+ time_path = output_dir / 'time_axis_ns.npy'
305
+ int_path = output_dir / 'stitched_intensity.tif'
306
+ flim_path = output_dir / 'stitched_flim_counts.npy'
307
+ time_axis = np.load(str(time_path))
308
+ intensity = tifffile.imread(str(int_path))
309
+ flim = np.memmap(str(flim_path), dtype=np.uint32, mode=mode,
310
+ shape=(canvas_shape[0], canvas_shape[1], n_time_bins))
311
+ return flim, time_axis, intensity, metadata
312
+
313
+ def _close_memmap(arr):
314
+ mm = getattr(arr, '_mmap', None)
315
+ if mm is not None:
316
+ mm.close()
317
+
318
+ def load_flim_for_fitting(
319
+ source_dir,
320
+ load_to_memory=False,
321
+ ):
322
+ flim_memmap, _, _, metadata = load_stitched_flim(source_dir)
323
+ tcspc_res = metadata['tcspc_resolution_ps'] * 1e-12
324
+ n_bins = metadata['n_time_bins']
325
+ if load_to_memory == True:
326
+ stack = np.array(flim_memmap, dtype=np.float32)
327
+ _close_memmap(flim_memmap)
328
+ else:
329
+ stack = flim_memmap
330
+ return stack, tcspc_res, n_bins
331
+
332
+ def _peek_tile_width(ptu_dir, tile_positions, rotate_tiles):
333
+ for t in tile_positions:
334
+ p = Path(ptu_dir) / t['file']
335
+ if p.exists():
336
+ _, meta = get_flim_histogram_from_ptufile(
337
+ p, rotate_cw=rotate_tiles, binning=1, channel=None)
338
+ return meta['tile_shape'][1]
339
+ raise FileNotFoundError('No tile PTU files found to determine tile width')
340
+
341
+ def _resolve_tile_irf(ptu_name, irf_xlsx_dir=None, irf_xlsx_map=None):
342
+ stem = Path(ptu_name).stem
343
+ if irf_xlsx_map:
344
+ if ptu_name in irf_xlsx_map:
345
+ return irf_xlsx_map[ptu_name]
346
+ if stem in irf_xlsx_map:
347
+ return irf_xlsx_map[stem]
348
+ if irf_xlsx_dir is not None:
349
+ candidate = Path(irf_xlsx_dir) / f"{stem}.xlsx"
350
+ if candidate.exists():
351
+ return candidate
352
+ return None
353
+
354
+ def _load_machine_irf(path):
355
+ irf = np.asarray(np.load(str(path)), dtype=float).ravel()
356
+ irf = np.maximum(irf, 0.0)
357
+ s = irf.sum()
358
+ if s <= 0:
359
+ raise ValueError(f"Machine IRF is all-zero: {path}")
360
+ irf /= s
361
+ return irf, int(np.argmax(irf))
362
+
363
+ def _get_tile_irf(machine_irf, pi_machine,
364
+ tile_peak_bin, n_bins):
365
+ irf = machine_irf.copy()
366
+ if irf.size > n_bins:
367
+ irf = irf[:n_bins]
368
+ elif irf.size < n_bins:
369
+ irf = np.pad(irf, (0, n_bins - irf.size))
370
+ shift = tile_peak_bin - pi_machine
371
+ if shift != 0:
372
+ irf = np.roll(irf, shift)
373
+ s = irf.sum()
374
+ return irf / s if s > 0 else irf
375
+
376
+ def _adapt_pixel_maps(pixel_maps, n_exp,
377
+ taus_ns):
378
+ ny, nx = pixel_maps['intensity'].shape
379
+ adapted = {
380
+ 'intensity': pixel_maps['intensity'],
381
+ 'tau_mean_amp': pixel_maps['tau_mean_amp'],
382
+ 'tau_mean_int': pixel_maps.get(
383
+ 'tau_mean_int', np.full((ny, nx), np.nan, dtype=np.float32)),
384
+ 'chi2': pixel_maps['chi2_r'],
385
+ }
386
+ if 'calibrated_chi2_r' in pixel_maps:
387
+ adapted['calibrated_chi2_r'] = pixel_maps['calibrated_chi2_r']
388
+ for k in range(1, n_exp + 1):
389
+ adapted[f'tau{k}'] = np.full((ny, nx), taus_ns[k - 1], dtype=np.float32)
390
+ adapted[f'a{k}'] = pixel_maps.get(
391
+ f'alpha_{k}', np.full((ny, nx), np.nan, dtype=np.float32))
392
+ return adapted
393
+
394
+ def _phase_corr_2d(patch_a, patch_b, max_shift_y=120, max_shift_x=30):
395
+ h = min(patch_a.shape[0], patch_b.shape[0])
396
+ w = min(patch_a.shape[1], patch_b.shape[1])
397
+ pa = patch_a[:h, :w].astype(np.float64)
398
+ pb = patch_b[:h, :w].astype(np.float64)
399
+ wy = np.hanning(h)
400
+ wx = np.hanning(w)
401
+ win = wy[:, np.newaxis] * wx[np.newaxis, :]
402
+ pa = (pa - pa.mean()) * win
403
+ pb = (pb - pb.mean()) * win
404
+ Fa = np.fft.fft2(pa)
405
+ Fb = np.fft.fft2(pb)
406
+ cross = Fa * np.conj(Fb)
407
+ denom = np.abs(cross)
408
+ denom[denom < 1e-10] = 1e-10
409
+ corr = np.real(np.fft.ifft2(cross / denom))
410
+ corr_s = np.fft.fftshift(corr)
411
+ cy, cx = h // 2, w // 2
412
+ mask = np.zeros_like(corr_s)
413
+ y_lo = max(0, cy - max_shift_y); y_hi = min(h, cy + max_shift_y + 1)
414
+ x_lo = max(0, cx - max_shift_x); x_hi = min(w, cx + max_shift_x + 1)
415
+ mask[y_lo:y_hi, x_lo:x_hi] = 1
416
+ corr_s *= mask
417
+ pk_y, pk_x = np.unravel_index(np.argmax(corr_s), corr_s.shape)
418
+ peak_val = corr_s[pk_y, pk_x]
419
+ confidence = peak_val / (corr_s[y_lo:y_hi, x_lo:x_hi].mean() + 1e-10)
420
+ def _sub(arr, pk, lo, hi):
421
+ if lo < pk < hi - 1:
422
+ a, b, c = arr[pk-1], arr[pk], arr[pk+1]
423
+ if a > 0 and b > 0 and c > 0:
424
+ try:
425
+ la, lb, lc = np.log(a), np.log(b), np.log(c)
426
+ return pk + (la - lc) / (2 * (la - 2*lb + lc))
427
+ except Exception:
428
+ pass
429
+ return float(pk)
430
+ sub_y = _sub(corr_s[:, pk_x], pk_y, y_lo, y_hi) - cy
431
+ sub_x = _sub(corr_s[pk_y, :], pk_x, x_lo, x_hi) - cx
432
+ return sub_y, sub_x, confidence
433
+
434
+ def _register_tile_columns(tile_results, max_shift_px=120, verbose=True,):
435
+ REG_MAX_SHIFT_Y = max_shift_px
436
+ REG_MAX_SHIFT_X = 30
437
+ MIN_CONF = 5.0
438
+ MAD_THRESHOLD = 3.0
439
+ MIN_TISSUE_FRAC = 0.05
440
+ if not tile_results:
441
+ return tile_results
442
+ orig_col_xs = sorted(set(int(round(tr['pixel_x']/10)*10) for tr in tile_results))
443
+ orig_row_ys = sorted(set(int(round(tr['pixel_y']/10)*10) for tr in tile_results))
444
+ tile_w = max(tr['tile_w'] for tr in tile_results)
445
+ tile_h = max(tr['tile_h'] for tr in tile_results)
446
+ col_pitch = int(np.median(np.diff(orig_col_xs))) if len(orig_col_xs)>1 else tile_w
447
+ row_pitch = int(np.median(np.diff(orig_row_ys))) if len(orig_row_ys)>1 else tile_h
448
+ col_overlap = tile_w - col_pitch
449
+ row_overlap = tile_h - row_pitch
450
+ N_rows = len(orig_row_ys)
451
+ N_cols = len(orig_col_xs)
452
+ if col_overlap < 4:
453
+ if verbose:
454
+ print(f' Registration: col_overlap={col_overlap}px too small - skipping')
455
+ return tile_results
456
+ if verbose:
457
+ print(f' Registration: {N_rows}r×{N_cols}c '
458
+ f'col_overlap={col_overlap}px row_overlap={row_overlap}px')
459
+ orig_grid = {}
460
+ for i, tr in enumerate(tile_results):
461
+ try:
462
+ ci = orig_col_xs.index(int(round(tr['pixel_x']/10)*10))
463
+ except ValueError:
464
+ ci = min(range(N_cols), key=lambda c: abs(orig_col_xs[c]-tr['pixel_x']))
465
+ try:
466
+ ri = orig_row_ys.index(int(round(tr['pixel_y']/10)*10))
467
+ except ValueError:
468
+ ri = min(range(N_rows), key=lambda r: abs(orig_row_ys[r]-tr['pixel_y']))
469
+ tile_results[i]['_orig_row_idx'] = ri
470
+ tile_results[i]['_orig_col_idx'] = ci
471
+ orig_grid[(ri, ci)] = i
472
+ def _prep(strip, gamma=0.5):
473
+ s = strip.astype(np.float64)
474
+ if s.max() > 0: s = (s/s.max())**gamma * s.max()
475
+ return s
476
+ def _mad_wmean(vals, wts, thr):
477
+ vals = np.array(vals, dtype=float)
478
+ wts = np.array(wts, dtype=float)
479
+ med = np.median(vals)
480
+ mad = max(np.median(np.abs(vals - med)), 0.5)
481
+ keep = np.abs(vals - med) <= thr * mad
482
+ if not keep.any():
483
+ return float(med), 0, len(vals)
484
+ return (float(np.average(vals[keep], weights=wts[keep])),
485
+ int((~keep).sum()), len(vals))
486
+ if verbose:
487
+ print(' Pass A: column Y drift')
488
+ col_shift = {}
489
+ for ci in range(N_cols-1):
490
+ dys, confs = [], []
491
+ for ri in range(N_rows):
492
+ ti = orig_grid.get((ri, ci))
493
+ tj = orig_grid.get((ri, ci+1))
494
+ if ti is None or tj is None: continue
495
+ Ii = np.asarray(tile_results[ti]['pixel_maps']['intensity'], dtype=float)
496
+ Ij = np.asarray(tile_results[tj]['pixel_maps']['intensity'], dtype=float)
497
+ sa = _prep(Ii[:, col_pitch:col_pitch+col_overlap])
498
+ sb = _prep(Ij[:, :col_overlap])
499
+ mr = min(sa.shape[0], sb.shape[0])
500
+ if mr<20 or sa[:mr].max()<0.5 or sb[:mr].max()<0.5: continue
501
+ dy, dx, conf = _phase_corr_2d(sa[:mr], sb[:mr],
502
+ max_shift_y=REG_MAX_SHIFT_Y,
503
+ max_shift_x=max(4, col_overlap//4))
504
+ if conf >= MIN_CONF:
505
+ dys.append(dy); confs.append(conf)
506
+ if not dys:
507
+ col_shift[ci] = 0.0
508
+ continue
509
+ s, _, _ = _mad_wmean(dys, confs, MAD_THRESHOLD)
510
+ col_shift[ci] = s
511
+ if verbose:
512
+ print(f' col {orig_col_xs[ci]:5d}→{orig_col_xs[ci+1]:5d}: {s:+.2f}px')
513
+ cum_col_y = np.zeros(N_cols)
514
+ for ci in range(1, N_cols):
515
+ cum_col_y[ci] = cum_col_y[ci-1] + col_shift.get(ci-1, 0.0)
516
+ if verbose:
517
+ print(f' Cumulative: {[round(v,1) for v in cum_col_y]}')
518
+ for i, tr in enumerate(tile_results):
519
+ ci = tr['_orig_col_idx']
520
+ corr = int(round(float(cum_col_y[ci])))
521
+ if corr:
522
+ tile_results[i]['pixel_y'] = max(0, tr['pixel_y'] + corr)
523
+ if verbose:
524
+ print(' Pass B: row Y residual')
525
+ row_shift_y = {}
526
+ for ri in range(N_rows-1):
527
+ dys, confs = [], []
528
+ for ci in range(N_cols):
529
+ ti = orig_grid.get((ri, ci))
530
+ tj = orig_grid.get((ri+1, ci))
531
+ if ti is None or tj is None: continue
532
+ Ii = np.asarray(tile_results[ti]['pixel_maps']['intensity'], dtype=float)
533
+ Ij = np.asarray(tile_results[tj]['pixel_maps']['intensity'], dtype=float)
534
+ sa = _prep(Ii[row_pitch:row_pitch+row_overlap, :])
535
+ sb = _prep(Ij[:row_overlap, :])
536
+ mr = min(sa.shape[0], sb.shape[0])
537
+ mc = min(sa.shape[1], sb.shape[1])
538
+ if mr<20 or sa[:mr,:mc].max()<0.5 or sb[:mr,:mc].max()<0.5: continue
539
+ dy, dx, conf = _phase_corr_2d(sa[:mr,:mc], sb[:mr,:mc],
540
+ max_shift_y=max(4, row_overlap//4),
541
+ max_shift_x=REG_MAX_SHIFT_X)
542
+ tf = min((sa[:mr,:mc]>1).mean(), (sb[:mr,:mc]>1).mean())
543
+ if conf >= MIN_CONF and tf >= MIN_TISSUE_FRAC:
544
+ dys.append(dy); confs.append(conf)
545
+ if not dys:
546
+ row_shift_y[ri] = 0.0
547
+ continue
548
+ s, _, _ = _mad_wmean(dys, confs, MAD_THRESHOLD)
549
+ row_shift_y[ri] = s
550
+ if verbose:
551
+ print(f' row {ri} ({orig_row_ys[ri]}→{orig_row_ys[ri+1]}): {s:+.2f}px')
552
+ cum_row_y = np.zeros(N_rows)
553
+ for ri in range(1, N_rows):
554
+ cum_row_y[ri] = cum_row_y[ri-1] + row_shift_y.get(ri-1, 0.0)
555
+ if verbose:
556
+ print(f' Cumulative: {[round(v,1) for v in cum_row_y]}')
557
+ for i, tr in enumerate(tile_results):
558
+ ri = tr['_orig_row_idx']
559
+ corr = int(round(float(cum_row_y[ri])))
560
+ if corr:
561
+ tile_results[i]['pixel_y'] = max(0, tr['pixel_y'] + corr)
562
+ if verbose:
563
+ print(' Pass C: row X residual')
564
+ row_shift_x = {}
565
+ for ri in range(N_rows-1):
566
+ dxs, confs = [], []
567
+ for ci in range(N_cols):
568
+ ti = orig_grid.get((ri, ci))
569
+ tj = orig_grid.get((ri+1, ci))
570
+ if ti is None or tj is None: continue
571
+ Ii = np.asarray(tile_results[ti]['pixel_maps']['intensity'], dtype=float)
572
+ Ij = np.asarray(tile_results[tj]['pixel_maps']['intensity'], dtype=float)
573
+ sa = _prep(Ii[row_pitch:row_pitch+row_overlap, :])
574
+ sb = _prep(Ij[:row_overlap, :])
575
+ mr = min(sa.shape[0], sb.shape[0])
576
+ mc = min(sa.shape[1], sb.shape[1])
577
+ if mr<20 or sa[:mr,:mc].max()<0.5 or sb[:mr,:mc].max()<0.5: continue
578
+ dy, dx, conf = _phase_corr_2d(sa[:mr,:mc], sb[:mr,:mc],
579
+ max_shift_y=max(4, row_overlap//4),
580
+ max_shift_x=REG_MAX_SHIFT_X)
581
+ tf = min((sa[:mr,:mc]>1).mean(), (sb[:mr,:mc]>1).mean())
582
+ if conf >= MIN_CONF and tf >= MIN_TISSUE_FRAC:
583
+ dxs.append(dx); confs.append(conf)
584
+ if not dxs:
585
+ row_shift_x[ri] = 0.0
586
+ continue
587
+ s, _, _ = _mad_wmean(dxs, confs, MAD_THRESHOLD)
588
+ row_shift_x[ri] = s
589
+ if verbose:
590
+ print(f' row {ri} (y={orig_row_ys[ri]}): dx={s:+.2f}px')
591
+ cum_row_x = np.zeros(N_rows)
592
+ for ri in range(1, N_rows):
593
+ cum_row_x[ri] = cum_row_x[ri-1] + row_shift_x.get(ri-1, 0.0)
594
+ if verbose:
595
+ print(f' X cumulative: {[round(v,1) for v in cum_row_x]}')
596
+ for i, tr in enumerate(tile_results):
597
+ ri = tr['_orig_row_idx']
598
+ corr = int(round(float(cum_row_x[ri])))
599
+ if corr:
600
+ tile_results[i]['pixel_x'] = max(0, tr['pixel_x'] + corr)
601
+ n_corrected = sum(
602
+ 1 for tr in tile_results
603
+ if tr.get('_orig_row_idx',0)>0 or tr.get('_orig_col_idx',0)>0
604
+ )
605
+ if verbose:
606
+ canvas_h = max(tr['pixel_y']+tr['tile_h'] for tr in tile_results)
607
+ canvas_w = max(tr['pixel_x']+tr['tile_w'] for tr in tile_results)
608
+ print(f' Registration complete. Canvas: {canvas_h}×{canvas_w}px')
609
+ return tile_results
610
+
611
+ def pool_series_decay(ptu_dir, index, args, stride=10, rotate_tiles=True,
612
+ verbose=True, cancel_event=None):
613
+ from .reader import PTUFile
614
+ from ...FLIM.fitters import fit_summed
615
+ from ...FLIM.bg_tools import tvb_from_decay
616
+ from ...configs import (
617
+ MACHINE_IRF_DEFAULT_PATH,
618
+ MACHINE_IRF_FIT_BG, MACHINE_IRF_FIT_SIGMA, MACHINE_IRF_FIT_TAIL,
619
+ MACHINE_IRF_SIGMA_MAX_FULL, MACHINE_IRF_SIGMA_MAX_HALF,
620
+ Tau_min, Tau_max, n_exp as _cfg_nexp,
621
+ Cost_function, Optimizer, lm_restarts, n_workers,
622
+ )
623
+ ptu_dir = Path(ptu_dir)
624
+ n_exp_ = getattr(args, 'nexp', _cfg_nexp)
625
+ fit_sigma = MACHINE_IRF_FIT_SIGMA
626
+ sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
627
+ estimate_irf = getattr(args, 'estimate_irf', 'machine_irf')
628
+ if estimate_irf == 'machine_irf_sigma_full':
629
+ fit_sigma = True
630
+ elif estimate_irf == 'machine_irf_sigma_half':
631
+ fit_sigma = True
632
+ sigma_max = MACHINE_IRF_SIGMA_MAX_HALF
633
+ mach_path = getattr(args, 'machine_irf', str(MACHINE_IRF_DEFAULT_PATH))
634
+ machine_irf, pi_machine = _load_machine_irf(mach_path)
635
+ timepoints = index['timepoints'][::max(1, stride)]
636
+ planes = [k for k in sorted(index['planes']) if k[0] in set(timepoints)]
637
+ if verbose:
638
+ print(f'Pooling decay over {len(planes)} of {len(index["planes"])} planes '
639
+ f'(every {stride} timepoint(s))...')
640
+ pooled_decay = None
641
+ n_bins_ref = None
642
+ tcspc_ref = None
643
+ n_files = 0
644
+ for key in planes:
645
+ if cancel_event is not None and cancel_event.is_set():
646
+ break
647
+ for entry in index['planes'][key]:
648
+ path = ptu_dir / entry['file']
649
+ if not path.exists():
650
+ continue
651
+ ptu = PTUFile(str(path), verbose=False)
652
+ decay = ptu.summed_decay()
653
+ n_files += 1
654
+ if pooled_decay is None:
655
+ pooled_decay = decay.astype(np.float64).copy()
656
+ n_bins_ref = ptu.n_bins
657
+ tcspc_ref = ptu.tcspc_res
658
+ continue
659
+ if ptu.n_bins > n_bins_ref:
660
+ pooled_decay = np.pad(pooled_decay, (0, ptu.n_bins - n_bins_ref))
661
+ n_bins_ref = ptu.n_bins
662
+ if len(decay) < len(pooled_decay):
663
+ decay = np.pad(decay, (0, len(pooled_decay) - len(decay)))
664
+ pooled_decay[:len(decay)] += decay[:len(pooled_decay)]
665
+ if pooled_decay is None:
666
+ raise RuntimeError(f'No readable PTU files under {ptu_dir}')
667
+ pooled_peak = int(np.argmax(pooled_decay))
668
+ pooled_irf = _get_tile_irf(machine_irf, pi_machine, pooled_peak, n_bins_ref)
669
+ if verbose:
670
+ print(f' Pooled {n_files} files, {pooled_decay.sum():,.0f} photons, '
671
+ f'peak bin {pooled_peak}')
672
+ print(' Running consensus fit_summed on pooled decay...')
673
+ _tvb_ptu_path = getattr(args, 'tvb_ptu', None)
674
+ _tvb_pooled = None
675
+ _fit_tvb = False
676
+ if _tvb_ptu_path:
677
+ _tvb_ref = PTUFile(str(_tvb_ptu_path), verbose=False)
678
+ _tvb_pooled = tvb_from_decay(
679
+ _tvb_ref.summed_decay(channel=getattr(args, 'tvb_channel', None)),
680
+ n_bins_ref, src_tcspc_res=_tvb_ref.tcspc_res, dst_tcspc_res=tcspc_ref)
681
+ _fit_tvb = True
682
+ global_popt, global_summary = fit_summed(
683
+ pooled_decay, tcspc_ref, n_bins_ref, pooled_irf,
684
+ has_tail = MACHINE_IRF_FIT_TAIL,
685
+ fit_bg = MACHINE_IRF_FIT_BG,
686
+ fit_sigma = fit_sigma,
687
+ n_exp = n_exp_,
688
+ tau_min_ns = getattr(args, 'tau_min', Tau_min),
689
+ tau_max_ns = getattr(args, 'tau_max', Tau_max),
690
+ optimizer = getattr(args, 'optimizer', Optimizer),
691
+ cost_function = getattr(args, 'cost_function', Cost_function),
692
+ n_restarts = getattr(args, 'restarts', lm_restarts),
693
+ workers = getattr(args, 'workers', n_workers),
694
+ sigma_max = sigma_max,
695
+ tvb_profile = _tvb_pooled,
696
+ fit_tvb = _fit_tvb,
697
+ )
698
+ if verbose:
699
+ taus = global_summary['taus_ns']
700
+ print(f" Consensus τ = {[f'{t:.3f}' for t in taus]} ns")
701
+ print(f" χ²_r (tail) = {global_summary['reduced_chi2_tail']:.4f}")
702
+ return {
703
+ 'pooled_decay': pooled_decay,
704
+ 'pooled_irf': pooled_irf,
705
+ 'pooled_peak': pooled_peak,
706
+ 'n_bins': n_bins_ref,
707
+ 'tcspc': tcspc_ref,
708
+ 'global_popt': global_popt,
709
+ 'global_summary': global_summary,
710
+ 'n_files_pooled': n_files,
711
+ 'stride': stride,
712
+ }
713
+
714
+ def _series_positions_from_metadata(xlif_path, ptu_dir, index, rotate_tiles,
715
+ binning, verbose=True, refine=True,
716
+ refine_radius=60):
717
+ from .reader import PTUFile
718
+ from .series import refine_tile_positions, _tile_intensity
719
+ xlif_path = Path(xlif_path)
720
+ basename = index['base']
721
+ positions = parse_tile_positions(xlif_path, basename)
722
+ if len(positions) != len(index['tiles']):
723
+ raise RuntimeError(
724
+ f'{xlif_path.name} describes {len(positions)} tiles but the series '
725
+ f"has {len(index['tiles'])}; check it is the matching metadata file")
726
+ pixel_size_m, _ = get_pixel_size(xlif_path, basename)
727
+ first = index['planes'][sorted(index['planes'])[0]][0]
728
+ ptu = PTUFile(str(Path(ptu_dir) / first['file']), verbose=False)
729
+ tile_w = (ptu.n_y if rotate_tiles else ptu.n_x) // binning
730
+ positions, _, _ = compute_tile_pixel_positions(
731
+ positions, pixel_size_m * binning, tile_w)
732
+ for p, s in zip(positions, index['tiles']):
733
+ p['s'] = s
734
+ if verbose:
735
+ print(f'Tile positions from {xlif_path.name}:')
736
+ for p in positions:
737
+ print(f" s{p['s']}: pixel_y={p['pixel_y']} pixel_x={p['pixel_x']}")
738
+ if not refine:
739
+ return positions
740
+ plane = sorted(index['planes'])[len(index['planes']) // 2]
741
+ entries = sorted(index['planes'][plane], key=lambda e: e['s'])
742
+ images = [_tile_intensity(Path(ptu_dir) / e['file'], rotate_tiles, binning)
743
+ for e in entries]
744
+ positions, refinements = refine_tile_positions(
745
+ images, positions, radius=refine_radius)
746
+ if verbose:
747
+ print(f'Refined against the images at t={plane[0]} z={plane[1]}:')
748
+ for p, ref in zip(positions[1:], refinements):
749
+ if ref is None:
750
+ print(f" s{p['s']}: no overlap large enough to refine")
751
+ continue
752
+ print(f" s{p['s']}: moved {ref['shift_y']:+d},{ref['shift_x']:+d} px "
753
+ f"r {ref['correlation_before']:.3f} -> {ref['correlation']:.3f}")
754
+ return positions
755
+
756
+ def fit_flim_series(
757
+ ptu_dir,
758
+ output_dir,
759
+ args,
760
+ ptu_basename=None,
761
+ rotate_tiles=True,
762
+ tile_positions=None,
763
+ xlif_path=None,
764
+ pool_stride=10,
765
+ pooled=None,
766
+ verbose=True,
767
+ progress_callback=None,
768
+ cancel_event=None,
769
+ ):
770
+ from .series import index_ptu_series, describe_series, recover_series_positions, plane_tile_positions
771
+ from ...FLIM.assemble import assemble_tile_maps, save_assembled_maps
772
+ ptu_dir = Path(ptu_dir)
773
+ output_dir = Path(output_dir)
774
+ output_dir.mkdir(parents=True, exist_ok=True)
775
+ index = index_ptu_series(ptu_dir, ptu_basename=ptu_basename)
776
+ if verbose:
777
+ print(f"\n{'='*60}")
778
+ print(' MULTIDIMENSIONAL SERIES FIT')
779
+ print(f"{'='*60}")
780
+ print(f' {describe_series(index)}')
781
+ if index['is_ragged']:
782
+ raise RuntimeError(
783
+ 'Series has a different tile count on different planes; '
784
+ 'the missing files must be restored before stitching')
785
+ if tile_positions is None and xlif_path:
786
+ tile_positions = _series_positions_from_metadata(
787
+ xlif_path, ptu_dir, index, rotate_tiles,
788
+ getattr(args, 'binning', 1), verbose)
789
+ if tile_positions is None:
790
+ tile_positions, _ = recover_series_positions(
791
+ ptu_dir, index, rotate_tiles=rotate_tiles,
792
+ binning=getattr(args, 'binning', 1), verbose=verbose)
793
+ if pooled is None:
794
+ pooled = pool_series_decay(
795
+ ptu_dir, index, args, stride=pool_stride,
796
+ rotate_tiles=rotate_tiles, verbose=verbose, cancel_event=cancel_event)
797
+ n_exp_ = getattr(args, 'nexp', 2)
798
+ roi_base = index['base'].replace(' ', '_')
799
+ planes = sorted(index['planes'])
800
+ written = []
801
+ for i, key in enumerate(planes):
802
+ if cancel_event is not None and cancel_event.is_set():
803
+ if verbose:
804
+ print('\nSeries fit cancelled by user.')
805
+ break
806
+ if progress_callback is not None:
807
+ progress_callback(i, len(planes))
808
+ t_index, z_index = key
809
+ plane_positions = plane_tile_positions(tile_positions, index['planes'][key])
810
+ plane_name = f'{roi_base}_t{t_index}_z{z_index}'
811
+ if verbose:
812
+ print(f"\n[{i+1}/{len(planes)}] t={t_index} z={z_index}")
813
+ (tile_results, canvas_h, canvas_w, corrected_positions,
814
+ _, _, _, _, plane_summary) = fit_flim_tiles(
815
+ xlif_path = None,
816
+ ptu_dir = ptu_dir,
817
+ output_dir = output_dir,
818
+ args = args,
819
+ ptu_basename = index['base'],
820
+ rotate_tiles = rotate_tiles,
821
+ verbose = False,
822
+ cancel_event = cancel_event,
823
+ tile_positions = plane_positions,
824
+ pooled = pooled,
825
+ )
826
+ if not tile_results:
827
+ if verbose:
828
+ print(f' no tiles fitted for t={t_index} z={z_index}, skipped')
829
+ continue
830
+ canvas = assemble_tile_maps(
831
+ tile_results = tile_results,
832
+ canvas_height = canvas_h,
833
+ canvas_width = canvas_w,
834
+ n_exp = n_exp_,
835
+ )
836
+ plane_dir = output_dir / plane_name
837
+ save_assembled_maps(
838
+ canvas = canvas,
839
+ global_summary = plane_summary,
840
+ output_dir = plane_dir,
841
+ roi_name = plane_name,
842
+ n_exp = n_exp_,
843
+ tau_display_min = getattr(args, 'tau_display_min', None),
844
+ tau_display_max = getattr(args, 'tau_display_max', None),
845
+ )
846
+ written.append({
847
+ 't': t_index,
848
+ 'z': z_index,
849
+ 'name': plane_name,
850
+ 'dir': str(plane_dir.relative_to(output_dir)),
851
+ 'canvas_height': int(canvas_h),
852
+ 'canvas_width': int(canvas_w),
853
+ 'n_tiles': len(tile_results),
854
+ })
855
+ if verbose:
856
+ print(f' wrote {plane_name} ({canvas_h}x{canvas_w}, '
857
+ f'{len(tile_results)} tiles)')
858
+ manifest = {
859
+ 'base': index['base'],
860
+ 'timepoints': index['timepoints'],
861
+ 'z_planes': index['z_planes'],
862
+ 'tiles': index['tiles'],
863
+ 'n_planes_written': len(written),
864
+ 'pool_stride': pooled.get('stride'),
865
+ 'n_files_pooled': pooled.get('n_files_pooled'),
866
+ 'consensus_taus_ns': [float(x) for x in pooled['global_summary']['taus_ns']],
867
+ 'pooled_peak_bin': int(pooled['pooled_peak']),
868
+ 'tile_positions': [
869
+ {'s': p['s'], 'pixel_y': p['pixel_y'], 'pixel_x': p['pixel_x']}
870
+ for p in tile_positions],
871
+ 'planes': written,
872
+ }
873
+ manifest_path = output_dir / f'{roi_base}_series_index.json'
874
+ with open(manifest_path, 'w') as f:
875
+ json.dump(manifest, f, indent=2)
876
+ if verbose:
877
+ print(f"\n{'='*60}")
878
+ print(f' {len(written)}/{len(planes)} planes written to {output_dir}')
879
+ print(f' Manifest: {manifest_path}')
880
+ print(f"{'='*60}")
881
+ return manifest
882
+
883
+ def fit_flim_tiles(
884
+ xlif_path,
885
+ ptu_dir,
886
+ output_dir,
887
+ args,
888
+ ptu_basename='R 2',
889
+ rotate_tiles=True,
890
+ irf_xlsx_dir=None,
891
+ irf_xlsx_map=None,
892
+ verbose=True,
893
+ progress_callback=None,
894
+ cancel_event=None,
895
+ tile_positions=None,
896
+ pooled=None,
897
+ ):
898
+ from .reader import PTUFile
899
+ from ...FLIM.fitters import fit_summed, fit_per_pixel
900
+ from ...FLIM.bg_tools import tvb_from_decay
901
+ from ...configs import (
902
+ MACHINE_IRF_DEFAULT_PATH,
903
+ MACHINE_IRF_FIT_BG, MACHINE_IRF_FIT_SIGMA, MACHINE_IRF_FIT_TAIL,
904
+ MACHINE_IRF_SIGMA_MAX_FULL, MACHINE_IRF_SIGMA_MAX_HALF,
905
+ MIN_PHOTONS_PERPIX,
906
+ Tau_min, Tau_max, n_exp as _cfg_nexp,
907
+ Cost_function, Optimizer, lm_restarts, n_workers,
908
+ binning_factor,
909
+ )
910
+ if xlif_path is None and tile_positions is None:
911
+ raise ValueError('fit_flim_tiles needs either xlif_path or tile_positions')
912
+ xlif_path = Path(xlif_path) if xlif_path is not None else None
913
+ ptu_dir = Path(ptu_dir)
914
+ output_dir = Path(output_dir)
915
+ output_dir.mkdir(parents=True, exist_ok=True)
916
+ n_exp_ = getattr(args, 'nexp', _cfg_nexp)
917
+ tau_min_ns = getattr(args, 'tau_min', Tau_min)
918
+ tau_max_ns = getattr(args, 'tau_max', Tau_max)
919
+ cost_fn = getattr(args, 'cost_function', Cost_function)
920
+ optimizer = getattr(args, 'optimizer', Optimizer)
921
+ restarts = getattr(args, 'restarts', lm_restarts)
922
+ workers = getattr(args, 'workers', n_workers)
923
+ binning = getattr(args, 'binning', binning_factor)
924
+ min_photons = getattr(args, 'min_photons', MIN_PHOTONS_PERPIX)
925
+ intensity_thr = getattr(args, 'intensity_threshold', None)
926
+ register_tiles = getattr(args, 'register_tiles', True)
927
+ reg_max_shift_px = getattr(args, 'reg_max_shift_px', 120)
928
+ fit_bg = MACHINE_IRF_FIT_BG
929
+ fit_sigma = MACHINE_IRF_FIT_SIGMA
930
+ has_tail = MACHINE_IRF_FIT_TAIL
931
+ sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
932
+ estimate_irf = getattr(args, 'estimate_irf', 'machine_irf')
933
+ if estimate_irf == 'machine_irf_sigma_full':
934
+ fit_sigma = True
935
+ sigma_max = MACHINE_IRF_SIGMA_MAX_FULL
936
+ elif estimate_irf == 'machine_irf_sigma_half':
937
+ fit_sigma = True
938
+ sigma_max = MACHINE_IRF_SIGMA_MAX_HALF
939
+ mach_path = getattr(args, 'machine_irf', str(MACHINE_IRF_DEFAULT_PATH))
940
+ machine_irf, pi_machine = _load_machine_irf(mach_path)
941
+ _tvb_ptu_path = getattr(args, 'tvb_ptu', None)
942
+ _tvb_bg_raw = None
943
+ _tvb_bg_res = None
944
+ if _tvb_ptu_path:
945
+ _tvb_ref = PTUFile(str(_tvb_ptu_path), verbose=False)
946
+ _tvb_chan = getattr(args, 'tvb_channel', None)
947
+ if _tvb_chan is None:
948
+ _tvb_chan = getattr(args, 'channel', None)
949
+ _tvb_bg_raw = _tvb_ref.summed_decay(channel=_tvb_chan)
950
+ _tvb_bg_res = _tvb_ref.tcspc_res
951
+ if verbose:
952
+ print(f" TVB background from: {_tvb_ptu_path} ({float(_tvb_bg_raw.sum()):,.0f} photons)")
953
+ _fit_tvb = _tvb_bg_raw is not None
954
+ if tile_positions is None:
955
+ tile_positions = parse_tile_positions(xlif_path, ptu_basename)
956
+ if 'pixel_x' in tile_positions[0] and 'pixel_y' in tile_positions[0]:
957
+ tile_w = _peek_tile_width(ptu_dir, tile_positions, rotate_tiles) // binning
958
+ canvas_w = max(t['pixel_x'] for t in tile_positions) + tile_w
959
+ canvas_h = max(t['pixel_y'] for t in tile_positions) + tile_w
960
+ else:
961
+ pixel_size_m, _ = get_pixel_size(xlif_path, ptu_basename)
962
+ effective_pixel_size_m = pixel_size_m * binning
963
+ tile_positions, canvas_w, canvas_h = compute_tile_pixel_positions(
964
+ tile_positions, effective_pixel_size_m,
965
+ _peek_tile_width(ptu_dir, tile_positions, rotate_tiles) // binning)
966
+ if verbose:
967
+ print(f"\n{'='*60}")
968
+ print(f" PER-TILE FLIM FITTING - POOLED MACHINE IRF")
969
+ print(f"{'='*60}")
970
+ print(f" Metadata: {xlif_path}")
971
+ print(f" PTUs: {ptu_dir}")
972
+ print(f" Tiles: {len(tile_positions)}")
973
+ print(f" Canvas: {canvas_h} × {canvas_w} px")
974
+ print(f" Machine IRF: {mach_path} (peak bin {pi_machine})\n")
975
+ total_steps = 2 * len(tile_positions)
976
+ if pooled is not None:
977
+ pooled_decay = pooled['pooled_decay']
978
+ pooled_irf = pooled['pooled_irf']
979
+ pooled_peak = pooled['pooled_peak']
980
+ n_bins_ref = pooled['n_bins']
981
+ tcspc_ref = pooled['tcspc']
982
+ global_popt = pooled['global_popt']
983
+ global_summary = pooled['global_summary']
984
+ tile_meta = []
985
+ for t in tile_positions:
986
+ ptu_path = ptu_dir / t['file']
987
+ if not ptu_path.exists():
988
+ continue
989
+ ptu = PTUFile(str(ptu_path), verbose=False)
990
+ tile_meta.append({
991
+ 't': t,
992
+ 'n_bins': ptu.n_bins,
993
+ 'tcspc': ptu.tcspc_res,
994
+ 'peak_bin': pooled_peak,
995
+ })
996
+ if not tile_meta:
997
+ raise RuntimeError('No tiles found - check PTU_DIR and PTU_BASENAME.')
998
+ if verbose:
999
+ print(f'Pass 1 skipped: reusing pooled fit '
1000
+ f'({len(tile_meta)} tiles, peak bin {pooled_peak})')
1001
+ else:
1002
+ if verbose:
1003
+ print('Pass 1: accumulating pooled decay (summed_decay only)...')
1004
+ tile_meta = []
1005
+ pooled_decay = None
1006
+ n_bins_ref = None
1007
+ tcspc_ref = None
1008
+ for i, t in enumerate(tqdm(tile_positions,
1009
+ desc=' Pass 1', disable=True)):
1010
+ if cancel_event is not None and cancel_event.is_set():
1011
+ break
1012
+ if progress_callback is not None:
1013
+ progress_callback(i, total_steps)
1014
+ ptu_path = ptu_dir / t['file']
1015
+ if not ptu_path.exists():
1016
+ continue
1017
+ ptu = PTUFile(str(ptu_path), verbose=False)
1018
+ decay = ptu.summed_decay()
1019
+ n_bins = ptu.n_bins
1020
+ tcspc = ptu.tcspc_res
1021
+ if intensity_thr is not None:
1022
+ stack_p1 = ptu.raw_pixel_stack(channel=ptu.photon_channel)
1023
+ px_int = stack_p1.sum(axis=-1)
1024
+ mask_p1 = px_int >= intensity_thr
1025
+ stack_p1[~mask_p1] = 0
1026
+ decay = stack_p1.sum(axis=(0, 1))
1027
+ del stack_p1, px_int, mask_p1
1028
+ if pooled_decay is None:
1029
+ pooled_decay = decay.copy()
1030
+ n_bins_ref = n_bins
1031
+ tcspc_ref = tcspc
1032
+ else:
1033
+ if n_bins > n_bins_ref:
1034
+ pooled_decay = np.pad(pooled_decay, (0, n_bins - n_bins_ref))
1035
+ n_bins_ref = n_bins
1036
+ if len(decay) < len(pooled_decay):
1037
+ decay = np.pad(decay, (0, len(pooled_decay) - len(decay)))
1038
+ pooled_decay[:len(decay)] += decay[:len(pooled_decay)]
1039
+ tile_meta.append({
1040
+ 't': t,
1041
+ 'n_bins': n_bins,
1042
+ 'tcspc': tcspc,
1043
+ 'peak_bin': int(np.argmax(decay)),
1044
+ })
1045
+ if pooled_decay is None:
1046
+ raise RuntimeError('No tiles found - check PTU_DIR and PTU_BASENAME.')
1047
+ pooled_peak = int(np.argmax(pooled_decay))
1048
+ pooled_irf = _get_tile_irf(machine_irf, pi_machine, pooled_peak, n_bins_ref)
1049
+ if verbose:
1050
+ print(f"\n Pooled: {len(tile_meta)} tiles "
1051
+ f"{pooled_decay.sum():,.0f} photons peak bin {pooled_peak}")
1052
+ print('\n Running consensus fit_summed on pooled decay...')
1053
+ _tvb_pooled = (tvb_from_decay(_tvb_bg_raw, n_bins_ref,
1054
+ src_tcspc_res=_tvb_bg_res, dst_tcspc_res=tcspc_ref)
1055
+ if _fit_tvb else None)
1056
+ global_popt, global_summary = fit_summed(
1057
+ pooled_decay, tcspc_ref, n_bins_ref, pooled_irf,
1058
+ has_tail = has_tail,
1059
+ fit_bg = fit_bg,
1060
+ fit_sigma = fit_sigma,
1061
+ n_exp = n_exp_,
1062
+ tau_min_ns = tau_min_ns,
1063
+ tau_max_ns = tau_max_ns,
1064
+ optimizer = optimizer,
1065
+ cost_function = cost_fn,
1066
+ n_restarts = restarts,
1067
+ workers = workers,
1068
+ sigma_max = sigma_max,
1069
+ tvb_profile = _tvb_pooled,
1070
+ fit_tvb = _fit_tvb,
1071
+ )
1072
+ consensus_taus_ns = global_summary['taus_ns']
1073
+ if verbose:
1074
+ print(f"\n Consensus τ = {[f'{t:.3f}' for t in consensus_taus_ns]} ns")
1075
+ print(f" χ²_r (tail) = {global_summary['reduced_chi2_tail']:.4f}")
1076
+ popt_for_px = global_popt.copy()
1077
+ popt_for_px[2 * n_exp_] = 0.0
1078
+ tile_results = []
1079
+ tiles_skipped = 0
1080
+ for i, tc in enumerate(tqdm(tile_meta,
1081
+ desc=' Pass 2', disable=True, leave=False)):
1082
+ if i == 0 and verbose:
1083
+ tqdm.write(f"Pass 2: per-pixel fit ({len(tile_meta)} tiles)...")
1084
+ tqdm.write(f" Fixed τ = {[f'{t:.3f}' for t in consensus_taus_ns]} ns")
1085
+ tqdm.write(f" Fixed IRF = pooled_irf (peak bin {pooled_peak})\n")
1086
+ if cancel_event is not None and cancel_event.is_set():
1087
+ break
1088
+ if progress_callback is not None:
1089
+ progress_callback(len(tile_meta) + i, total_steps)
1090
+ ptu_path = ptu_dir / tc['t']['file']
1091
+ n_bins = tc['n_bins']
1092
+ tcspc = tc['tcspc']
1093
+ if len(pooled_irf) < n_bins:
1094
+ irf_tile = np.pad(pooled_irf, (0, n_bins - len(pooled_irf)))
1095
+ else:
1096
+ irf_tile = pooled_irf[:n_bins]
1097
+ irf_tile = irf_tile / irf_tile.sum()
1098
+ try:
1099
+ ptu = PTUFile(str(ptu_path), verbose=False)
1100
+ ptu.summed_decay()
1101
+ stack = ptu.raw_pixel_stack(
1102
+ channel=ptu.photon_channel, binning=binning)
1103
+ if rotate_tiles:
1104
+ stack = np.rot90(stack, k=-1, axes=(0, 1))
1105
+ tile_h, tile_w = stack.shape[:2]
1106
+ if intensity_thr is not None:
1107
+ px_int = stack.sum(axis=-1)
1108
+ stack[px_int < intensity_thr] = 0
1109
+ del px_int
1110
+ _tvb_tile = (tvb_from_decay(_tvb_bg_raw, n_bins,
1111
+ src_tcspc_res=_tvb_bg_res, dst_tcspc_res=tcspc)
1112
+ if _fit_tvb else None)
1113
+ pixel_maps_raw = fit_per_pixel(
1114
+ stack.astype(float),
1115
+ tcspc, n_bins, irf_tile,
1116
+ has_tail = has_tail,
1117
+ fit_bg = fit_bg,
1118
+ fit_sigma = fit_sigma,
1119
+ global_popt = popt_for_px,
1120
+ n_exp = n_exp_,
1121
+ min_photons = min_photons,
1122
+ tau_min_ns = tau_min_ns,
1123
+ tau_max_ns = tau_max_ns,
1124
+ correct_pileup = getattr(args, 'correct_pileup', False),
1125
+ n_sync = getattr(ptu, 'n_sync', None),
1126
+ fit_idx = global_summary.get('fit_idx'),
1127
+ free_tau = getattr(args, 'free_tau_perpixel', False),
1128
+ tvb_profile = _tvb_tile,
1129
+ fit_tvb = _fit_tvb,
1130
+ )
1131
+ del stack
1132
+ pixel_maps = _adapt_pixel_maps(pixel_maps_raw, n_exp_, consensus_taus_ns)
1133
+ n_fitted = int(np.isfinite(pixel_maps['tau_mean_amp']).sum())
1134
+ tile_results.append({
1135
+ 'pixel_maps': pixel_maps,
1136
+ 'global_summary': global_summary,
1137
+ 'pixel_y': tc['t']['pixel_y'],
1138
+ 'pixel_x': tc['t']['pixel_x'],
1139
+ 'tile_h': tile_h,
1140
+ 'tile_w': tile_w,
1141
+ 'peak_bin': tc['peak_bin'],
1142
+ 'ptu_name': tc['t']['file'],
1143
+ })
1144
+ if verbose:
1145
+ tqdm.write(
1146
+ f" {tc['t']['file']:<30} "
1147
+ f"{pixel_maps['intensity'].sum():>10,.0f} ph "
1148
+ f"fitted={n_fitted}")
1149
+ except Exception as e:
1150
+ import traceback, sys
1151
+ if verbose:
1152
+ tqdm.write(f" ERROR: {tc['t']['file']}: {e}", file=sys.stderr)
1153
+ tqdm.write(traceback.format_exc(), file=sys.stderr)
1154
+ tiles_skipped += 1
1155
+ continue
1156
+ if verbose:
1157
+ print(f"\n {len(tile_results)}/{len(tile_meta)} tiles fitted "
1158
+ f"({tiles_skipped} errors)")
1159
+ if register_tiles and len(tile_results) > 1:
1160
+ tile_results = _register_tile_columns(
1161
+ tile_results,
1162
+ max_shift_px=reg_max_shift_px,
1163
+ verbose=verbose,
1164
+ )
1165
+ canvas_h = max(tr['pixel_y'] + tr['tile_h'] for tr in tile_results)
1166
+ canvas_w = max(tr['pixel_x'] + tr['tile_w'] for tr in tile_results)
1167
+ if verbose:
1168
+ print(f' Canvas after registration: {canvas_h}×{canvas_w} px')
1169
+ _pos_by_file = {tr['ptu_name']: tr for tr in tile_results}
1170
+ corrected_positions = [
1171
+ {
1172
+ **tc['t'],
1173
+ 'pixel_y': _pos_by_file[tc['t']['file']]['pixel_y']
1174
+ if tc['t']['file'] in _pos_by_file
1175
+ else tc['t']['pixel_y'],
1176
+ 'pixel_x': _pos_by_file[tc['t']['file']]['pixel_x']
1177
+ if tc['t']['file'] in _pos_by_file
1178
+ else tc['t']['pixel_x'],
1179
+ }
1180
+ for tc in tile_meta
1181
+ ]
1182
+ return tile_results, canvas_h, canvas_w, corrected_positions, pooled_decay, pooled_irf, tcspc_ref, global_popt, global_summary