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.
- flimkit/FLIM/__init__.py +0 -0
- flimkit/FLIM/assemble.py +254 -0
- flimkit/FLIM/batch.py +681 -0
- flimkit/FLIM/bg_tools.py +51 -0
- flimkit/FLIM/fit_tools.py +244 -0
- flimkit/FLIM/fitters.py +1471 -0
- flimkit/FLIM/irf_tools.py +617 -0
- flimkit/FLIM/models.py +391 -0
- flimkit/GPU/__init__.py +85 -0
- flimkit/GPU/_base.py +391 -0
- flimkit/GPU/cuda.py +10 -0
- flimkit/GPU/mlx_backend.py +381 -0
- flimkit/GPU/mps.py +10 -0
- flimkit/GPU/rocm.py +10 -0
- flimkit/GPU/torch_backend.py +385 -0
- flimkit/UI/app_state.py +10 -0
- flimkit/UI/controller.py +139 -0
- flimkit/UI/expert_settings.py +248 -0
- flimkit/UI/fit_help.py +206 -0
- flimkit/UI/fov_preview.py +1085 -0
- flimkit/UI/gui.py +3919 -0
- flimkit/UI/icon.icns +0 -0
- flimkit/UI/icon.ico +0 -0
- flimkit/UI/icon.png +0 -0
- flimkit/UI/irf_widget.py +103 -0
- flimkit/UI/mode_controller.py +118 -0
- flimkit/UI/modes/__init__.py +0 -0
- flimkit/UI/modes/base.py +3 -0
- flimkit/UI/modes/batch_mode.py +312 -0
- flimkit/UI/modes/fov_mode.py +164 -0
- flimkit/UI/modes/irf_mode.py +80 -0
- flimkit/UI/modes/phasor_mode.py +131 -0
- flimkit/UI/modes/stitch_mode.py +254 -0
- flimkit/UI/phasor_panel.py +1087 -0
- flimkit/UI/progress_window.py +113 -0
- flimkit/UI/project_panel.py +262 -0
- flimkit/UI/results_panel.py +332 -0
- flimkit/UI/roi_tools.py +794 -0
- flimkit/UI/utils.py +217 -0
- flimkit/__init__.py +0 -0
- flimkit/_version.py +41 -0
- flimkit/cli.py +120 -0
- flimkit/configs.py +148 -0
- flimkit/dialogs.py +46 -0
- flimkit/formats/BH/__init__.py +0 -0
- flimkit/formats/BH/reader.py +296 -0
- flimkit/formats/BH/writer.py +86 -0
- flimkit/formats/ISS/__init__.py +0 -0
- flimkit/formats/ISS/fdflim.py +86 -0
- flimkit/formats/ISS/image.py +114 -0
- flimkit/formats/ISS/reader.py +223 -0
- flimkit/formats/PS/__init__.py +0 -0
- flimkit/formats/PS/reader.py +202 -0
- flimkit/formats/PTU/__init__.py +0 -0
- flimkit/formats/PTU/decode.py +27 -0
- flimkit/formats/PTU/phu.py +85 -0
- flimkit/formats/PTU/reader.py +235 -0
- flimkit/formats/PTU/series.py +258 -0
- flimkit/formats/PTU/stitch.py +1182 -0
- flimkit/formats/PTU/tools.py +94 -0
- flimkit/formats/__init__.py +2 -0
- flimkit/formats/flim_file.py +232 -0
- flimkit/formats/phasor.py +132 -0
- flimkit/formats/signal.py +170 -0
- flimkit/image/tools.py +124 -0
- flimkit/interactive.py +1857 -0
- flimkit/mpl_backend.py +22 -0
- flimkit/phasor/__init__.py +40 -0
- flimkit/phasor/filters.py +127 -0
- flimkit/phasor/fret.py +654 -0
- flimkit/phasor/interactive.py +556 -0
- flimkit/phasor/peaks.py +186 -0
- flimkit/phasor/signal.py +90 -0
- flimkit/phasor_launcher.py +314 -0
- flimkit/plugins/__init__.py +137 -0
- flimkit/plugins/bindings.py +116 -0
- flimkit/plugins/builtin/__init__.py +3 -0
- flimkit/plugins/builtin/core_tools.py +28 -0
- flimkit/plugins/loader.py +371 -0
- flimkit/plugins/registry.py +406 -0
- flimkit/project.py +197 -0
- flimkit/synth.py +145 -0
- flimkit/utils/__init__.py +0 -0
- flimkit/utils/batch_fit.py +301 -0
- flimkit/utils/config_manager.py +119 -0
- flimkit/utils/config_snapshot.py +30 -0
- flimkit/utils/crash_handler.py +183 -0
- flimkit/utils/display.py +197 -0
- flimkit/utils/enhanced_outputs.py +345 -0
- flimkit/utils/fancy.py +103 -0
- flimkit/utils/lifetime_image.py +243 -0
- flimkit/utils/misc.py +111 -0
- flimkit/utils/plotting.py +190 -0
- flimkit/utils/roi.py +370 -0
- flimkit/utils/session.py +51 -0
- flimkit/utils/update_check.py +198 -0
- flimkit/utils/xlsx_tools.py +97 -0
- flimkit/utils/xml_utils.py +219 -0
- flimkit-0.12.0.dist-info/METADATA +356 -0
- flimkit-0.12.0.dist-info/RECORD +104 -0
- flimkit-0.12.0.dist-info/WHEEL +5 -0
- flimkit-0.12.0.dist-info/entry_points.txt +2 -0
- flimkit-0.12.0.dist-info/licenses/LICENSE.md +11 -0
- 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
|