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,235 @@
1
+ import math
2
+ import numpy as np
3
+ from pathlib import Path
4
+
5
+ def _load_ptufile():
6
+ try:
7
+ import ptufile
8
+ except ImportError:
9
+ raise ImportError('ptufile is required to read PicoQuant .ptu files '
10
+ '(pip install ptufile)')
11
+ return ptufile
12
+
13
+ def _norm_channel(channel):
14
+ if not isinstance(channel, str):
15
+ return channel
16
+ channel = channel.strip()
17
+ if channel == '':
18
+ return None
19
+ if channel.isdigit():
20
+ return int(channel)
21
+ return None
22
+
23
+ def _fit_bins(arr, n_bins):
24
+ h = arr.shape[-1]
25
+ if h == n_bins:
26
+ return arr
27
+ if h > n_bins:
28
+ return arr[..., :n_bins]
29
+ pad = [(0, 0)] * (arr.ndim - 1) + [(0, n_bins - h)]
30
+ return np.pad(arr, pad)
31
+
32
+ def _reduce_yxh(arr, dims):
33
+ d = list(dims)
34
+ if arr.ndim != len(d):
35
+ d = d[-arr.ndim:]
36
+ drop = tuple(i for i, x in enumerate(d) if x not in ('Y', 'X', 'H'))
37
+ if drop:
38
+ arr = arr.sum(axis=drop)
39
+ d = [x for x in d if x in ('Y', 'X', 'H')]
40
+ return np.transpose(arr, [d.index(x) for x in ('Y', 'X', 'H') if x in d])
41
+
42
+ def _bin_cube(cube, binning):
43
+ if binning <= 1:
44
+ return cube
45
+ ny, nx, nh = cube.shape
46
+ ny2, nx2 = (ny // binning) * binning, (nx // binning) * binning
47
+ cube = cube[:ny2, :nx2, :]
48
+ return cube.reshape(ny2 // binning, binning, nx2 // binning, binning, nh).sum(axis=(1, 3))
49
+
50
+ def read_pck(path):
51
+ ptufile = _load_ptufile()
52
+ try:
53
+ with ptufile.PqFile(str(path)) as f:
54
+ tags = dict(f.tags)
55
+ except Exception as exc:
56
+ raise ValueError(f'Not a PicoQuant Check (.pck) file: {path!r}') from exc
57
+ hist = tags.get('ChkHistogram')
58
+ if hist is None:
59
+ raise ValueError(f'No ChkHistogram block in {path!r} - not a Check/IRF .pck?')
60
+ hist = np.frombuffer(np.asarray(hist).tobytes(), dtype='<u4')
61
+ n_chan = max(1, int(tags.get('ChkChannels', 1)))
62
+ if hist.size % n_chan == 0:
63
+ hist = hist.reshape(n_chan, hist.size // n_chan)
64
+ else:
65
+ hist = hist.reshape(1, hist.size)
66
+ return hist, tags
67
+
68
+ class PTUFile:
69
+ def __init__(self, path, verbose=True):
70
+ self.path = str(path)
71
+ self.verbose = verbose
72
+ ptufile = _load_ptufile()
73
+ self._ptu = ptufile.PtuFile(self.path)
74
+ self._parse_meta()
75
+
76
+ def _parse_meta(self):
77
+ p = self._ptu
78
+ dims = dict(zip(p.dims, p.shape))
79
+ self.tcspc_res = float(p.tcspc_resolution)
80
+ gr = float(p.global_resolution) if p.global_resolution else 0.0
81
+ self.global_resolution = gr
82
+ self.sync_rate = (1.0 / gr) if gr else 0.0
83
+ self.period_ns = gr * 1e9
84
+ self.n_bins_stored = int(dims.get('H', 0))
85
+ if self.tcspc_res > 0 and gr > 0:
86
+ self.n_bins = int(math.ceil(gr / self.tcspc_res))
87
+ else:
88
+ self.n_bins = self.n_bins_stored
89
+ self.n_x = int(dims.get('X', 0))
90
+ self.n_y = int(dims.get('Y', 0))
91
+ self.is_image = bool(getattr(p, 'is_image', self.n_x > 0 and self.n_y > 0))
92
+ self._active = [int(c) for c in p.active_channels]
93
+ self.n_channels = len(self._active)
94
+ self.n_records = int(getattr(p, 'number_records', 0) or 0)
95
+ self.n_photons = int(getattr(p, 'number_photons', 0) or 0)
96
+ self.n_frames = int(getattr(p, 'number_images', 0) or 0) or 1
97
+ self.n_sync = int(getattr(p, 'global_acquisition_time', 0) or 0) or None
98
+ self.acq_time_s = float(getattr(p, 'acquisition_time', 0.0) or 0.0) or None
99
+ self.time_ns = (np.arange(self.n_bins) + 0.5) * self.tcspc_res * 1e9
100
+ self.photon_channel = None
101
+ self.tags = dict(p.tags)
102
+ self.rec_type = int(self.tags.get('TTResultFormat_TTTRRecType', 0))
103
+ self._total_photons = None
104
+ if self.verbose:
105
+ print(f" PTU : {Path(self.path).name}")
106
+ print(f" RecType : 0x{self.rec_type:08X}")
107
+ print(f" TCSPC : {self.n_bins} bins x {self.tcspc_res*1e12:.2f} ps")
108
+ if self.n_bins_stored > self.n_bins:
109
+ print(f" ({self.n_bins_stored} stored, using {self.n_bins} within one laser period)")
110
+ print(f" Laser : {self.sync_rate/1e6:.3f} MHz ({self.period_ns:.3f} ns)")
111
+ if self.is_image:
112
+ print(f" Image : {self.n_x} x {self.n_y} px, {self.n_channels} channel(s)")
113
+ else:
114
+ print(f" Point : {self.n_channels} channel(s)")
115
+ print(f" Records : {self.n_records:,} ({self.n_photons:,} photons)")
116
+ if self.n_sync:
117
+ print(f" Sync : {self.n_sync:,} pulses over {self.acq_time_s or 0.0:.1f} s"
118
+ f" ({self.n_photons / self.n_sync:.4f} photons/pulse)")
119
+ print(' ')
120
+
121
+ def close(self):
122
+ try:
123
+ self._ptu.close()
124
+ except Exception:
125
+ pass
126
+
127
+ def __enter__(self):
128
+ return self
129
+
130
+ def __exit__(self, exc_type, exc, tb):
131
+ self.close()
132
+
133
+ @property
134
+ def photons_per_pulse(self):
135
+ if self._total_photons is None or not self.n_sync:
136
+ return None
137
+ return self._total_photons / self.n_sync
138
+
139
+ def _ch_pos(self, channel):
140
+ channel = int(channel)
141
+ if channel in self._active:
142
+ return self._active.index(channel)
143
+ if 0 <= channel < self.n_channels:
144
+ return channel
145
+ if self.n_channels == 1:
146
+ return 0
147
+ raise ValueError(f'Channel {channel} not in PTU active channels {self._active}')
148
+
149
+ def summed_decay(self, channel=None):
150
+ channel = _norm_channel(channel)
151
+ h = np.asarray(self._ptu.decode_histogram(asxarray=False), dtype=float)
152
+ if h.ndim == 1:
153
+ h = h[None, :]
154
+ if channel is None:
155
+ pos = int(np.argmax(h.sum(axis=1)))
156
+ self.photon_channel = self._active[pos] if self._active else 0
157
+ else:
158
+ pos = self._ch_pos(channel)
159
+ decay = _fit_bins(h[pos], self.n_bins).astype(float)
160
+ self._total_photons = int(decay.sum())
161
+ return decay
162
+
163
+ def _decode_cube(self, channel):
164
+ channel = _norm_channel(channel)
165
+ if channel is None:
166
+ if self.photon_channel is None:
167
+ self.summed_decay(channel=None)
168
+ channel = self.photon_channel
169
+ pos = self._ch_pos(channel)
170
+ p = self._ptu
171
+ dims = dict(zip(p.dims, p.shape))
172
+ ny, nx = int(dims.get('Y', 0)), int(dims.get('X', 0))
173
+ if ny == 0 or nx == 0:
174
+ raise RuntimeError('PTU has no scan image (point-mode); use summed_decay')
175
+ arr = np.asarray(p.decode_image(channel=pos, frame=-1, asxarray=False))
176
+ cube = _reduce_yxh(arr, p.dims)
177
+ return _fit_bins(cube, self.n_bins).astype(np.uint32)
178
+
179
+ def raw_pixel_stack(self, channel=None, binning=1):
180
+ cube = self._decode_cube(channel)
181
+ if binning > 1:
182
+ cube = _bin_cube(cube, binning)
183
+ self._total_photons = int(cube.sum())
184
+ return cube.astype(np.uint32)
185
+
186
+ def pixel_stack(self, channel=None, binning=1):
187
+ return self.raw_pixel_stack(channel=channel, binning=binning).astype(float)
188
+
189
+ def intensity_image(self, channel=None, binning=1):
190
+ return self.raw_pixel_stack(channel=channel, binning=binning).sum(axis=2)
191
+
192
+ def _metadata(ptu, data):
193
+ return {
194
+ 'frequency': ptu.sync_rate,
195
+ 'tcspc_resolution': ptu.tcspc_res,
196
+ 'shape': data.shape,
197
+ 'dims': ('Y', 'X', 'H'),
198
+ 'tags': ptu.tags,
199
+ 'x_pixel_size': ptu.tags.get('ImgHdr_PixResol', ptu.tags.get('ImgHdr_PixRes', 0)),
200
+ 'y_pixel_size': ptu.tags.get('ImgHdr_PixResol', ptu.tags.get('ImgHdr_PixRes', 0)),
201
+ 'n_bins': ptu.n_bins,
202
+ 'time_ns': ptu.time_ns,
203
+ 'photon_channel': ptu.photon_channel,
204
+ }
205
+
206
+ def read_ptu(path, binning=1, channel=None, verbose=False):
207
+ ptu = PTUFile(path, verbose=verbose)
208
+ data = ptu.pixel_stack(channel=channel, binning=binning)
209
+ metadata = _metadata(ptu, data)
210
+ return data, metadata
211
+
212
+ def get_intensity_image(path, binning=1, channel=None):
213
+ data, metadata = read_ptu(path, binning=binning, channel=channel, verbose=False)
214
+ img = data.sum(axis=2)
215
+ return img, metadata
216
+
217
+ def get_flim_data(path, binning=1, channel=None):
218
+ return read_ptu(path, binning=binning, channel=channel, verbose=False)
219
+
220
+ def normalise_flim(flim):
221
+ if flim is None:
222
+ return None
223
+ if flim.ndim == 5:
224
+ return flim[0, :, :, 0, :]
225
+ if flim.ndim == 4:
226
+ if flim.shape[0] == 1:
227
+ return flim[0]
228
+ else:
229
+ return flim[:, :, 0, :]
230
+ if flim.ndim == 3:
231
+ return flim
232
+ return None
233
+
234
+ def create_time_axis(n_bins, tcspc_resolution):
235
+ return np.arange(n_bins) * tcspc_resolution * 1e9
@@ -0,0 +1,258 @@
1
+ import re
2
+ import numpy as np
3
+ from pathlib import Path
4
+
5
+ TILE_ONLY_PATTERN = re.compile(r'^(?P<base>.+?)_s(?P<s>\d+)\.ptu$', re.IGNORECASE)
6
+
7
+ def parse_series_name(name):
8
+ from ...utils.batch_fit import parse_timelapse_filename
9
+ name = str(name)
10
+ parsed = parse_timelapse_filename(name)
11
+ if parsed is not None:
12
+ region, t, s, z = parsed
13
+ return {
14
+ 'file': name,
15
+ 'base': region,
16
+ 't': t,
17
+ 's': s or 1,
18
+ 'z': z or 1,
19
+ 'has_t': True,
20
+ 'has_z': z > 0,
21
+ }
22
+ m = TILE_ONLY_PATTERN.match(name)
23
+ if m is None:
24
+ return None
25
+ return {
26
+ 'file': name,
27
+ 'base': m.group('base'),
28
+ 't': 1,
29
+ 's': int(m.group('s')),
30
+ 'z': 1,
31
+ 'has_t': False,
32
+ 'has_z': False,
33
+ }
34
+
35
+ def index_ptu_series(ptu_dir, ptu_basename=None):
36
+ ptu_dir = Path(ptu_dir)
37
+ entries = []
38
+ for path in sorted(ptu_dir.glob('*.ptu')):
39
+ parsed = parse_series_name(path.name)
40
+ if parsed is None:
41
+ continue
42
+ if ptu_basename is not None and parsed['base'] != ptu_basename:
43
+ continue
44
+ entries.append(parsed)
45
+ if not entries:
46
+ raise RuntimeError(
47
+ f'No PTU files matching <base>_s<N>[_z<N>] found in {ptu_dir}'
48
+ + (f' for basename {ptu_basename!r}' if ptu_basename else ''))
49
+ bases = sorted({e['base'] for e in entries})
50
+ if len(bases) > 1:
51
+ raise RuntimeError(
52
+ f'Multiple series in {ptu_dir}: {bases}. Pass ptu_basename to pick one.')
53
+ planes = {}
54
+ for e in entries:
55
+ planes.setdefault((e['t'], e['z']), []).append(e)
56
+ for key in planes:
57
+ planes[key].sort(key=lambda e: e['s'])
58
+ tile_counts = {len(v) for v in planes.values()}
59
+ tiles = sorted({e['s'] for e in entries})
60
+ if len(tiles) < 2:
61
+ raise RuntimeError(
62
+ f'{bases[0]!r} has a single position, so there is nothing to stitch. '
63
+ 'Use the timelapse or z-stack batch fit for single-position stacks.')
64
+ return {
65
+ 'base': bases[0],
66
+ 'planes': planes,
67
+ 'timepoints': sorted({t for t, _ in planes}),
68
+ 'z_planes': sorted({z for _, z in planes}),
69
+ 'tiles': tiles,
70
+ 'n_files': len(entries),
71
+ 'is_ragged': len(tile_counts) > 1,
72
+ 'has_t': any(e['has_t'] for e in entries),
73
+ 'has_z': any(e['has_z'] for e in entries),
74
+ }
75
+
76
+ def describe_series(index):
77
+ n_t = len(index['timepoints'])
78
+ n_z = len(index['z_planes'])
79
+ n_s = len(index['tiles'])
80
+ return (f"{index['base']!r}: {n_t} timepoint(s) x {n_s} tile(s) x {n_z} z-plane(s) "
81
+ f"= {index['n_files']} files")
82
+
83
+ def _overlap_score(a, b, dy, dx, min_px):
84
+ h, w = a.shape
85
+ if abs(dy) >= h or abs(dx) >= w:
86
+ return None
87
+ pa = a[max(0, dy):min(h, h + dy), max(0, dx):min(w, w + dx)]
88
+ pb = b[max(0, -dy):min(h, h - dy), max(0, -dx):min(w, w - dx)]
89
+ if pa.size < min_px:
90
+ return None
91
+ pa = pa - pa.mean()
92
+ pb = pb - pb.mean()
93
+ den = np.sqrt(float((pa * pa).sum()) * float((pb * pb).sum()))
94
+ if den <= 0:
95
+ return None
96
+ return float((pa * pb).sum() / den), pa.size
97
+
98
+ def register_tile_pair(image_a, image_b, coarse_step=4, refine_radius=8,
99
+ min_overlap_frac=0.05, n_candidates=8):
100
+ a = np.log1p(np.asarray(image_a, dtype=float))
101
+ b = np.log1p(np.asarray(image_b, dtype=float))
102
+ if a.shape != b.shape:
103
+ raise ValueError(f'Tile shapes differ: {a.shape} vs {b.shape}')
104
+ h, w = a.shape
105
+ min_px = max(1, int(min_overlap_frac * a.size))
106
+ coarse = []
107
+ for dy in range(-h + 1, h, coarse_step):
108
+ for dx in range(-w + 1, w, coarse_step):
109
+ scored = _overlap_score(a, b, dy, dx, min_px)
110
+ if scored is None:
111
+ continue
112
+ coarse.append((scored[0], dy, dx, scored[1]))
113
+ if not coarse:
114
+ raise RuntimeError(
115
+ f'No candidate shift kept at least {100 * min_overlap_frac:.0f}% overlap')
116
+ coarse.sort(key=lambda c: (-c[0], -c[3]))
117
+ best = None
118
+ for _, cy, cx, _ in coarse[:n_candidates]:
119
+ for dy in range(cy - refine_radius, cy + refine_radius + 1):
120
+ for dx in range(cx - refine_radius, cx + refine_radius + 1):
121
+ scored = _overlap_score(a, b, dy, dx, min_px)
122
+ if scored is None:
123
+ continue
124
+ r, n = scored
125
+ if best is None or r > best[0] + 1e-9 or (
126
+ abs(r - best[0]) <= 1e-9 and n > best[3]):
127
+ best = (r, dy, dx, n)
128
+ r, dy, dx, n_px = best
129
+ return {
130
+ 'dy': int(dy),
131
+ 'dx': int(dx),
132
+ 'correlation': float(r),
133
+ 'overlap_px': int(n_px),
134
+ 'overlap_frac': float(n_px) / float(a.size),
135
+ }
136
+
137
+ def recover_tile_positions(tile_images, min_correlation=0.3):
138
+ if len(tile_images) < 2:
139
+ raise ValueError('Need at least two tiles to recover positions')
140
+ shifts = {0: (0, 0)}
141
+ pairs = []
142
+ for i in range(1, len(tile_images)):
143
+ reg = register_tile_pair(tile_images[i - 1], tile_images[i])
144
+ if reg['correlation'] < min_correlation:
145
+ raise RuntimeError(
146
+ f'Tile {i-1} to {i} registration too weak '
147
+ f"(r={reg['correlation']:.3f} < {min_correlation}); "
148
+ f'supply tile positions from a .lif or .xlif instead')
149
+ prev_y, prev_x = shifts[i - 1]
150
+ shifts[i] = (prev_y + reg['dy'], prev_x + reg['dx'])
151
+ pairs.append(reg)
152
+ ys = [shifts[i][0] for i in range(len(tile_images))]
153
+ xs = [shifts[i][1] for i in range(len(tile_images))]
154
+ min_y, min_x = min(ys), min(xs)
155
+ positions = [{'pixel_y': ys[i] - min_y, 'pixel_x': xs[i] - min_x}
156
+ for i in range(len(tile_images))]
157
+ return positions, pairs
158
+
159
+ def refine_tile_positions(tile_images, positions, radius=60,
160
+ min_overlap_frac=0.05):
161
+ if len(tile_images) < 2:
162
+ return [dict(p) for p in positions], []
163
+ imgs = [np.log1p(np.asarray(im, dtype=float)) for im in tile_images]
164
+ min_px = max(1, int(min_overlap_frac * imgs[0].size))
165
+ out = [dict(positions[0])]
166
+ refinements = []
167
+ for i in range(1, len(imgs)):
168
+ prev, cur = positions[i - 1], positions[i]
169
+ dy0 = cur['pixel_y'] - prev['pixel_y']
170
+ dx0 = cur['pixel_x'] - prev['pixel_x']
171
+ best = None
172
+ for dy in range(dy0 - radius, dy0 + radius + 1):
173
+ for dx in range(dx0 - radius, dx0 + radius + 1):
174
+ scored = _overlap_score(imgs[i - 1], imgs[i], dy, dx, min_px)
175
+ if scored is None:
176
+ continue
177
+ r, n = scored
178
+ if best is None or r > best[0]:
179
+ best = (r, dy, dx, n)
180
+ if best is None:
181
+ out.append(dict(cur))
182
+ refinements.append(None)
183
+ continue
184
+ r, dy, dx, n = best
185
+ start = _overlap_score(imgs[i - 1], imgs[i], dy0, dx0, min_px)
186
+ out.append({**cur,
187
+ 'pixel_y': out[i - 1]['pixel_y'] + dy,
188
+ 'pixel_x': out[i - 1]['pixel_x'] + dx})
189
+ refinements.append({
190
+ 'shift_y': dy - dy0,
191
+ 'shift_x': dx - dx0,
192
+ 'correlation': float(r),
193
+ 'correlation_before': float(start[0]) if start else float('nan'),
194
+ 'overlap_px': int(n),
195
+ })
196
+ min_y = min(p['pixel_y'] for p in out)
197
+ min_x = min(p['pixel_x'] for p in out)
198
+ for p in out:
199
+ p['pixel_y'] -= min_y
200
+ p['pixel_x'] -= min_x
201
+ return out, refinements
202
+
203
+ def _tile_intensity(ptu_path, rotate_cw, binning):
204
+ from .reader import PTUFile
205
+ ptu = PTUFile(str(ptu_path), verbose=False)
206
+ ptu.summed_decay()
207
+ img = ptu.intensity_image(channel=ptu.photon_channel, binning=binning)
208
+ if rotate_cw:
209
+ img = np.rot90(img, k=-1)
210
+ return img
211
+
212
+ def recover_series_positions(ptu_dir, index, plane=None, rotate_tiles=True,
213
+ binning=1, min_correlation=0.3, verbose=True):
214
+ ptu_dir = Path(ptu_dir)
215
+ if plane is None:
216
+ mid_t = index['timepoints'][len(index['timepoints']) // 2]
217
+ mid_z = index['z_planes'][len(index['z_planes']) // 2]
218
+ plane = (mid_t, mid_z)
219
+ entries = index['planes'].get(plane)
220
+ if not entries:
221
+ raise RuntimeError(f'Plane {plane} not present in series index')
222
+ if verbose:
223
+ print(f'Recovering tile positions from t={plane[0]} z={plane[1]} '
224
+ f'({len(entries)} tiles)...')
225
+ images = [_tile_intensity(ptu_dir / e['file'], rotate_tiles, binning)
226
+ for e in entries]
227
+ positions, pairs = recover_tile_positions(images, min_correlation=min_correlation)
228
+ tile_positions = []
229
+ for e, pos in zip(entries, positions):
230
+ tile_positions.append({
231
+ 'file': e['file'],
232
+ 's': e['s'],
233
+ 'scan_index': e['s'] - 1,
234
+ 'field_x': 0,
235
+ 'field_y': e['s'] - 1,
236
+ 'pos_x': 0.0,
237
+ 'pos_y': 0.0,
238
+ 'pixel_x': pos['pixel_x'],
239
+ 'pixel_y': pos['pixel_y'],
240
+ })
241
+ if verbose:
242
+ for i, reg in enumerate(pairs):
243
+ print(f" s{entries[i]['s']} to s{entries[i+1]['s']}: "
244
+ f"dy={reg['dy']} dx={reg['dx']} r={reg['correlation']:.3f} "
245
+ f"overlap={100 * reg['overlap_frac']:.1f}%")
246
+ return tile_positions, pairs
247
+
248
+ def plane_tile_positions(reference_positions, entries):
249
+ by_tile = {p['s']: p for p in reference_positions}
250
+ out = []
251
+ for e in sorted(entries, key=lambda x: x['s']):
252
+ ref = by_tile.get(e['s'])
253
+ if ref is None:
254
+ raise RuntimeError(
255
+ f"Tile s{e['s']} has no recovered position; the reference plane "
256
+ f'was missing that tile')
257
+ out.append({**ref, 'file': e['file']})
258
+ return out