nanoobjectdetection 2.0.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.
- nanoobjectdetection/__init__.py +24 -0
- nanoobjectdetection/__main__.py +6 -0
- nanoobjectdetection/application.py +670 -0
- nanoobjectdetection/auto_parameters.py +177 -0
- nanoobjectdetection/calibration.py +64 -0
- nanoobjectdetection/config.py +202 -0
- nanoobjectdetection/drift.py +47 -0
- nanoobjectdetection/gpu.py +126 -0
- nanoobjectdetection/hindrance.py +56 -0
- nanoobjectdetection/legacy_config.py +157 -0
- nanoobjectdetection/legacy_outputs.py +121 -0
- nanoobjectdetection/localization.py +73 -0
- nanoobjectdetection/msd.py +215 -0
- nanoobjectdetection/pipeline.py +874 -0
- nanoobjectdetection/plotting.py +234 -0
- nanoobjectdetection/preprocessing.py +56 -0
- nanoobjectdetection/progress.py +32 -0
- nanoobjectdetection/quality.py +133 -0
- nanoobjectdetection/statistics.py +59 -0
- nanoobjectdetection/stitching.py +97 -0
- nanoobjectdetection/storage.py +187 -0
- nanoobjectdetection/synthetic.py +28 -0
- nanoobjectdetection/tracking.py +75 -0
- nanoobjectdetection/trajectory_processing.py +81 -0
- nanoobjectdetection/ultra_uniform.py +52 -0
- nanoobjectdetection-2.0.0.dist-info/METADATA +249 -0
- nanoobjectdetection-2.0.0.dist-info/RECORD +31 -0
- nanoobjectdetection-2.0.0.dist-info/WHEEL +5 -0
- nanoobjectdetection-2.0.0.dist-info/entry_points.txt +2 -0
- nanoobjectdetection-2.0.0.dist-info/top_level.txt +2 -0
- nanoobjectdetection_2_0/__init__.py +30 -0
|
@@ -0,0 +1,874 @@
|
|
|
1
|
+
"""Orchestration for the independent blockwise pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import shutil
|
|
8
|
+
import tempfile
|
|
9
|
+
from concurrent.futures import ProcessPoolExecutor
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import IO
|
|
12
|
+
|
|
13
|
+
import pandas as pd
|
|
14
|
+
import numpy as np
|
|
15
|
+
|
|
16
|
+
from .config import AnalysisConfig
|
|
17
|
+
from .localization import locate
|
|
18
|
+
from .preprocessing import preprocess, to_uint16
|
|
19
|
+
from .storage import (
|
|
20
|
+
create_store,
|
|
21
|
+
iter_blocks,
|
|
22
|
+
iter_tiff_frames,
|
|
23
|
+
tiff_info,
|
|
24
|
+
to_processing_bit_depth,
|
|
25
|
+
)
|
|
26
|
+
from .tracking import link, remove_near_stationary
|
|
27
|
+
from .trajectory_processing import (
|
|
28
|
+
filter_minimum_frames,
|
|
29
|
+
split_intensity_jumps,
|
|
30
|
+
split_long_trajectories,
|
|
31
|
+
)
|
|
32
|
+
from .quality import filter_trajectories
|
|
33
|
+
from .statistics import diameter_summary
|
|
34
|
+
from .drift import correct_global
|
|
35
|
+
from .msd import estimate_all
|
|
36
|
+
from .ultra_uniform import ultra_uniform_mask
|
|
37
|
+
from .hindrance import corrected_diameter_nm, local_channel_diameter_nm
|
|
38
|
+
from .calibration import estimate_channel_center_from_tiff
|
|
39
|
+
from dataclasses import replace
|
|
40
|
+
|
|
41
|
+
from scipy.ndimage import gaussian_filter
|
|
42
|
+
|
|
43
|
+
from .auto_parameters import (
|
|
44
|
+
optimize_minmass,
|
|
45
|
+
red_x_from_theory,
|
|
46
|
+
sigma_psf_px,
|
|
47
|
+
trackpy_diameter,
|
|
48
|
+
zncc_particles,
|
|
49
|
+
)
|
|
50
|
+
from .progress import update
|
|
51
|
+
from .stitching import keep_dominant_particle, stitch_fragments
|
|
52
|
+
from .legacy_outputs import (
|
|
53
|
+
legacy_sizes_table,
|
|
54
|
+
legacy_trajectories,
|
|
55
|
+
)
|
|
56
|
+
from .progress import progress
|
|
57
|
+
from .plotting import create_analysis_plots
|
|
58
|
+
from .gpu import choose_chunk_frames, gpu_available, preprocess_gpu, to_uint16_gpu
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _available_memory_bytes() -> int | None:
|
|
62
|
+
try:
|
|
63
|
+
with open("/proc/meminfo", encoding="utf-8") as handle:
|
|
64
|
+
for line in handle:
|
|
65
|
+
if line.startswith("MemAvailable:"):
|
|
66
|
+
return int(line.split()[1]) * 1024
|
|
67
|
+
except OSError:
|
|
68
|
+
pass
|
|
69
|
+
return None
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class AnalysisPipeline:
|
|
73
|
+
def __init__(self, config: AnalysisConfig):
|
|
74
|
+
config.validate()
|
|
75
|
+
self.config = config
|
|
76
|
+
self.store: Path | None = None
|
|
77
|
+
self._temporary_store = False
|
|
78
|
+
self.channel_center_y_px = config.channel_center_y_px
|
|
79
|
+
self.plot_paths: dict[str, str] = {}
|
|
80
|
+
self.global_background: np.ndarray | None = None
|
|
81
|
+
self.global_offset: float | None = None
|
|
82
|
+
self.effective_chunk_frames = config.chunk_frames
|
|
83
|
+
self._stack: np.ndarray | None = None
|
|
84
|
+
self._stack_first = 0
|
|
85
|
+
self._background_stack_file: IO[bytes] | None = None
|
|
86
|
+
info = tiff_info(config.input_file)
|
|
87
|
+
self.source_pixel_dtype = np.dtype(info["dtype"])
|
|
88
|
+
if config.gpu_enabled:
|
|
89
|
+
if not gpu_available():
|
|
90
|
+
raise RuntimeError(
|
|
91
|
+
"GPU acceleration was requested, but CuPy/CUDA is unavailable. "
|
|
92
|
+
"Install a CUDA-compatible CuPy package or set gpu_enabled=False."
|
|
93
|
+
)
|
|
94
|
+
height, width = info["shape"][-2:]
|
|
95
|
+
self.effective_chunk_frames = choose_chunk_frames(
|
|
96
|
+
config.chunk_frames,
|
|
97
|
+
height,
|
|
98
|
+
width,
|
|
99
|
+
config.gpu_memory_fraction,
|
|
100
|
+
)
|
|
101
|
+
update(
|
|
102
|
+
f"GPU acceleration enabled; using {self.effective_chunk_frames} "
|
|
103
|
+
"frames per chunk"
|
|
104
|
+
)
|
|
105
|
+
|
|
106
|
+
def resolve_channel_center(self) -> float | None:
|
|
107
|
+
"""Use the configured center or estimate it from the input TIFF."""
|
|
108
|
+
if self.channel_center_y_px is not None:
|
|
109
|
+
return self.channel_center_y_px
|
|
110
|
+
if not self.config.auto_channel_center:
|
|
111
|
+
return None
|
|
112
|
+
self.channel_center_y_px = estimate_channel_center_from_tiff(
|
|
113
|
+
self.config.input_file,
|
|
114
|
+
self.config.frame_start,
|
|
115
|
+
self.config.frame_start + 300
|
|
116
|
+
if self.config.frame_stop is None
|
|
117
|
+
else min(self.config.frame_stop, self.config.frame_start + 300),
|
|
118
|
+
)
|
|
119
|
+
return self.channel_center_y_px
|
|
120
|
+
|
|
121
|
+
def prepare_store(self) -> dict:
|
|
122
|
+
update("Step 1/4: Preparing TIFF working store")
|
|
123
|
+
self.cleanup_store()
|
|
124
|
+
self.config.output_dir.mkdir(parents=True, exist_ok=True)
|
|
125
|
+
if self.config.processing_mode == "single_stack":
|
|
126
|
+
return {
|
|
127
|
+
"format": "single-stack",
|
|
128
|
+
"shape": list(tiff_info(self.config.input_file)["shape"]),
|
|
129
|
+
"chunks": [],
|
|
130
|
+
}
|
|
131
|
+
self.store = Path(
|
|
132
|
+
tempfile.mkdtemp(
|
|
133
|
+
prefix=".nanoobjectdetection-image-store-",
|
|
134
|
+
dir=self.config.output_dir,
|
|
135
|
+
)
|
|
136
|
+
)
|
|
137
|
+
self._temporary_store = True
|
|
138
|
+
created = False
|
|
139
|
+
try:
|
|
140
|
+
manifest = create_store(
|
|
141
|
+
self.config.input_file,
|
|
142
|
+
self.store,
|
|
143
|
+
self.config.frame_start,
|
|
144
|
+
self.config.frame_stop,
|
|
145
|
+
self.effective_chunk_frames,
|
|
146
|
+
self.config.chunk_overlap_frames,
|
|
147
|
+
self.config.roi.y_slice,
|
|
148
|
+
self.config.roi.x_slice,
|
|
149
|
+
self.config.processing_bit_depth,
|
|
150
|
+
)
|
|
151
|
+
created = True
|
|
152
|
+
return manifest
|
|
153
|
+
finally:
|
|
154
|
+
if not created:
|
|
155
|
+
self.cleanup_store()
|
|
156
|
+
|
|
157
|
+
def localize(self) -> tuple[pd.DataFrame, Path]:
|
|
158
|
+
try:
|
|
159
|
+
return self._localize()
|
|
160
|
+
finally:
|
|
161
|
+
try:
|
|
162
|
+
self._release_background_stack()
|
|
163
|
+
finally:
|
|
164
|
+
self.cleanup_store()
|
|
165
|
+
|
|
166
|
+
def cleanup_store(self) -> None:
|
|
167
|
+
store = self.store
|
|
168
|
+
if not self._temporary_store or store is None:
|
|
169
|
+
return
|
|
170
|
+
self._temporary_store = False
|
|
171
|
+
self.store = None
|
|
172
|
+
shutil.rmtree(store)
|
|
173
|
+
|
|
174
|
+
def _localize(self) -> tuple[pd.DataFrame, Path]:
|
|
175
|
+
update("Step 2/4: Localizing particles block by block")
|
|
176
|
+
detections: list[pd.DataFrame] = []
|
|
177
|
+
scale_max = None
|
|
178
|
+
if self.config.remove_static_background:
|
|
179
|
+
self.global_background = self._global_background()
|
|
180
|
+
if self.config.intensity_normalization == "global":
|
|
181
|
+
scale_max = self._global_processed_max()
|
|
182
|
+
self._calibrate_detection(scale_max)
|
|
183
|
+
_, _, height, width = self._source_range_and_shape()
|
|
184
|
+
stack = self._stack
|
|
185
|
+
resident = stack is not None
|
|
186
|
+
total_blocks = (
|
|
187
|
+
-(-len(stack) // self._block_frames(height, width))
|
|
188
|
+
if stack is not None
|
|
189
|
+
else None
|
|
190
|
+
)
|
|
191
|
+
workers = self.config.locate_workers
|
|
192
|
+
workers = (os.cpu_count() or 1) if workers == 0 else workers
|
|
193
|
+
pool = ProcessPoolExecutor(max_workers=workers) if workers > 1 else None
|
|
194
|
+
for absolute_start, raw_block in progress(
|
|
195
|
+
self._iter_blocks(),
|
|
196
|
+
desc="Localizing particles",
|
|
197
|
+
total=total_blocks,
|
|
198
|
+
unit="block",
|
|
199
|
+
):
|
|
200
|
+
if self.config.gpu_enabled:
|
|
201
|
+
processed = preprocess_gpu(
|
|
202
|
+
raw_block,
|
|
203
|
+
self.config.remove_camera_offset,
|
|
204
|
+
self.config.remove_laser_fluctuation,
|
|
205
|
+
self.config.remove_static_background,
|
|
206
|
+
self.config.background_method,
|
|
207
|
+
self.config.enhance_snr,
|
|
208
|
+
background=self.global_background,
|
|
209
|
+
offset=self.global_offset,
|
|
210
|
+
snr_sigma_px=self.config.effective_snr_sigma_px,
|
|
211
|
+
)
|
|
212
|
+
image_block = to_uint16_gpu(processed, scale_max=scale_max)
|
|
213
|
+
else:
|
|
214
|
+
processed = preprocess(
|
|
215
|
+
raw_block,
|
|
216
|
+
self.config.remove_camera_offset,
|
|
217
|
+
self.config.remove_laser_fluctuation,
|
|
218
|
+
self.config.remove_static_background,
|
|
219
|
+
self.config.background_method,
|
|
220
|
+
self.config.enhance_snr,
|
|
221
|
+
background=self.global_background,
|
|
222
|
+
offset=self.global_offset,
|
|
223
|
+
snr_sigma_px=self.config.effective_snr_sigma_px,
|
|
224
|
+
)
|
|
225
|
+
image_block = to_uint16(processed, scale_max=scale_max)
|
|
226
|
+
found = locate(
|
|
227
|
+
image_block,
|
|
228
|
+
self.config.particle_diameter,
|
|
229
|
+
self.config.particle_minmass,
|
|
230
|
+
self.config.particle_separation,
|
|
231
|
+
self.config.particle_percentile,
|
|
232
|
+
preprocess=not self.config.enhance_snr,
|
|
233
|
+
workers=workers,
|
|
234
|
+
executor=pool,
|
|
235
|
+
)
|
|
236
|
+
if not found.empty:
|
|
237
|
+
found = found.copy()
|
|
238
|
+
found["saturated"] = self._saturated_flags(raw_block, found)
|
|
239
|
+
found["frame"] += absolute_start
|
|
240
|
+
found["abstime"] = found["frame"] / self.config.fps
|
|
241
|
+
detections.append(found)
|
|
242
|
+
del raw_block, processed, image_block
|
|
243
|
+
if pool is not None:
|
|
244
|
+
pool.shutdown()
|
|
245
|
+
if not detections:
|
|
246
|
+
raise RuntimeError("No particles were localized in any image block")
|
|
247
|
+
result = pd.concat(detections, ignore_index=True)
|
|
248
|
+
if (
|
|
249
|
+
self.config.processing_mode == "chunked"
|
|
250
|
+
and self.config.chunk_overlap_frames
|
|
251
|
+
and not resident
|
|
252
|
+
):
|
|
253
|
+
result = result.drop_duplicates(subset=["frame", "x", "y"]).reset_index(
|
|
254
|
+
drop=True
|
|
255
|
+
)
|
|
256
|
+
path = self.config.output_dir / "detections.csv"
|
|
257
|
+
result.to_csv(path, index=False)
|
|
258
|
+
return result, path
|
|
259
|
+
|
|
260
|
+
def _release_background_stack(self) -> None:
|
|
261
|
+
stack = self._stack
|
|
262
|
+
background_stack_file = self._background_stack_file
|
|
263
|
+
self._stack = None
|
|
264
|
+
self._background_stack_file = None
|
|
265
|
+
try:
|
|
266
|
+
if isinstance(stack, np.memmap):
|
|
267
|
+
stack.flush()
|
|
268
|
+
finally:
|
|
269
|
+
if background_stack_file is not None:
|
|
270
|
+
background_stack_file.close()
|
|
271
|
+
|
|
272
|
+
def _saturated_flags(self, raw_block: np.ndarray, found: pd.DataFrame) -> np.ndarray:
|
|
273
|
+
"""Flag detections whose 3x3 neighbourhood reaches the saturation value."""
|
|
274
|
+
limit = self.config.saturated_pixel_value
|
|
275
|
+
if limit is None:
|
|
276
|
+
return np.zeros(len(found), dtype=bool)
|
|
277
|
+
if (
|
|
278
|
+
self.config.processing_bit_depth == 8
|
|
279
|
+
and self.source_pixel_dtype.kind == "u"
|
|
280
|
+
and self.source_pixel_dtype.itemsize == 2
|
|
281
|
+
):
|
|
282
|
+
limit = np.floor(limit / 256)
|
|
283
|
+
flags = np.zeros(len(found), dtype=bool)
|
|
284
|
+
height, width = raw_block.shape[1:]
|
|
285
|
+
for index, (frame, x, y) in enumerate(
|
|
286
|
+
zip(found["frame"].to_numpy(int), found["x"].to_numpy(), found["y"].to_numpy())
|
|
287
|
+
):
|
|
288
|
+
px, py = int(x), int(y)
|
|
289
|
+
window = raw_block[
|
|
290
|
+
frame,
|
|
291
|
+
max(py - 1, 0) : min(py + 2, height),
|
|
292
|
+
max(px - 1, 0) : min(px + 2, width),
|
|
293
|
+
]
|
|
294
|
+
flags[index] = window.size > 0 and window.max() >= limit
|
|
295
|
+
return flags
|
|
296
|
+
|
|
297
|
+
def _calibrate_detection(self, scale_max: float | None) -> None:
|
|
298
|
+
"""Set trackpy diameter and/or minmass automatically, as the 1.0 help functions do."""
|
|
299
|
+
config = self.config
|
|
300
|
+
if not (config.auto_diameter or config.auto_minmass):
|
|
301
|
+
return
|
|
302
|
+
updates: dict[str, float | int] = {}
|
|
303
|
+
if config.auto_diameter:
|
|
304
|
+
updates["particle_diameter"] = trackpy_diameter(
|
|
305
|
+
config.numerical_aperture,
|
|
306
|
+
config.wavelength_nm,
|
|
307
|
+
config.pixel_size_um,
|
|
308
|
+
config.enhance_snr,
|
|
309
|
+
)
|
|
310
|
+
update(f"Auto trackpy diameter: {updates['particle_diameter']} px")
|
|
311
|
+
if config.auto_minmass:
|
|
312
|
+
diameter = int(updates.get("particle_diameter", config.particle_diameter))
|
|
313
|
+
updates["particle_minmass"] = self._auto_minmass(diameter, scale_max)
|
|
314
|
+
update(f"Auto trackpy minmass: {updates['particle_minmass']:g}")
|
|
315
|
+
self.config = replace(config, **updates)
|
|
316
|
+
(config.output_dir / "auto_parameters.json").write_text(
|
|
317
|
+
json.dumps(updates, indent=2), encoding="utf-8"
|
|
318
|
+
)
|
|
319
|
+
|
|
320
|
+
def _auto_minmass(self, diameter: int, scale_max: float | None) -> float:
|
|
321
|
+
"""Find the minmass at which trackpy best reproduces a ZNCC particle search."""
|
|
322
|
+
config = self.config
|
|
323
|
+
info = tiff_info(config.input_file)
|
|
324
|
+
stop = info["shape"][0] if config.frame_stop is None else min(
|
|
325
|
+
config.frame_stop, info["shape"][0]
|
|
326
|
+
)
|
|
327
|
+
indices = np.unique(
|
|
328
|
+
np.round(np.linspace(config.frame_start, stop - 1, config.minmass_try_frames))
|
|
329
|
+
).astype(int)
|
|
330
|
+
wanted = set(indices.tolist())
|
|
331
|
+
if self._stack is not None:
|
|
332
|
+
frames = [self._stack[number - self._stack_first] for number in sorted(wanted)]
|
|
333
|
+
else:
|
|
334
|
+
frames = [
|
|
335
|
+
image[config.roi.y_slice, config.roi.x_slice]
|
|
336
|
+
for number, image in iter_tiff_frames(
|
|
337
|
+
config.input_file, config.frame_start, stop
|
|
338
|
+
)
|
|
339
|
+
if number in wanted
|
|
340
|
+
]
|
|
341
|
+
processed = preprocess(
|
|
342
|
+
np.stack(frames),
|
|
343
|
+
config.remove_camera_offset,
|
|
344
|
+
config.remove_laser_fluctuation,
|
|
345
|
+
config.remove_static_background,
|
|
346
|
+
config.background_method,
|
|
347
|
+
False,
|
|
348
|
+
background=self.global_background,
|
|
349
|
+
offset=self.global_offset,
|
|
350
|
+
)
|
|
351
|
+
sigma = sigma_psf_px(
|
|
352
|
+
config.numerical_aperture, config.wavelength_nm, config.pixel_size_um
|
|
353
|
+
)
|
|
354
|
+
update("Auto minmass: locating reference particles by ZNCC")
|
|
355
|
+
reference = zncc_particles(processed, sigma, config.enhance_snr, config.zncc_min)
|
|
356
|
+
if len(reference) == 0:
|
|
357
|
+
raise RuntimeError("ZNCC found no particles in the sampled frames; cannot set minmass")
|
|
358
|
+
if len(reference) < 200:
|
|
359
|
+
update(
|
|
360
|
+
f"Warning: only {len(reference)} reference particles; "
|
|
361
|
+
"consider raising minmass_try_frames"
|
|
362
|
+
)
|
|
363
|
+
trackpy_input = processed
|
|
364
|
+
if config.enhance_snr:
|
|
365
|
+
trackpy_input = gaussian_filter(
|
|
366
|
+
processed, sigma=(0, config.effective_snr_sigma_px, config.effective_snr_sigma_px)
|
|
367
|
+
)
|
|
368
|
+
image_block = to_uint16(trackpy_input, scale_max=scale_max)
|
|
369
|
+
update("Auto minmass: optimizing against trackpy")
|
|
370
|
+
candidates = locate(
|
|
371
|
+
image_block,
|
|
372
|
+
diameter,
|
|
373
|
+
1,
|
|
374
|
+
config.particle_separation,
|
|
375
|
+
config.particle_percentile,
|
|
376
|
+
preprocess=not config.enhance_snr,
|
|
377
|
+
workers=config.locate_workers,
|
|
378
|
+
)
|
|
379
|
+
optimum, _ = optimize_minmass(candidates, reference, diameter / 2.0)
|
|
380
|
+
return float(int(optimum * 0.90))
|
|
381
|
+
|
|
382
|
+
def _source_range_and_shape(self) -> tuple[int, int, int, int]:
|
|
383
|
+
"""Return first frame, stop frame, ROI height and ROI width of the analysed data."""
|
|
384
|
+
if self.config.processing_mode == "single_stack":
|
|
385
|
+
info = tiff_info(self.config.input_file)
|
|
386
|
+
total, height, width = info["shape"][-3:]
|
|
387
|
+
stop = total if self.config.frame_stop is None else min(self.config.frame_stop, total)
|
|
388
|
+
height = len(range(*self.config.roi.y_slice.indices(height)))
|
|
389
|
+
width = len(range(*self.config.roi.x_slice.indices(width)))
|
|
390
|
+
return self.config.frame_start, stop, height, width
|
|
391
|
+
manifest = json.loads(
|
|
392
|
+
(self._require_store() / "manifest.json").read_text(encoding="utf-8")
|
|
393
|
+
)
|
|
394
|
+
_, height, width = manifest["shape"]
|
|
395
|
+
return int(manifest["frame_start"]), int(manifest["frame_stop"]), height, width
|
|
396
|
+
|
|
397
|
+
def _iter_source_blocks(self, batch_frames: int):
|
|
398
|
+
"""Yield (first frame, ROI-cropped block) by reading the source exactly once."""
|
|
399
|
+
if self.config.processing_mode != "single_stack":
|
|
400
|
+
yield from iter_blocks(self._require_store())
|
|
401
|
+
return
|
|
402
|
+
frames: list[np.ndarray] = []
|
|
403
|
+
first_frame = self.config.frame_start
|
|
404
|
+
roi = self.config.roi
|
|
405
|
+
for frame_number, image in iter_tiff_frames(
|
|
406
|
+
self.config.input_file, self.config.frame_start, self.config.frame_stop
|
|
407
|
+
):
|
|
408
|
+
if not frames:
|
|
409
|
+
first_frame = frame_number
|
|
410
|
+
frames.append(
|
|
411
|
+
to_processing_bit_depth(
|
|
412
|
+
image[roi.y_slice, roi.x_slice],
|
|
413
|
+
self.config.processing_bit_depth,
|
|
414
|
+
)
|
|
415
|
+
)
|
|
416
|
+
if len(frames) == batch_frames:
|
|
417
|
+
yield first_frame, np.stack(frames, axis=0)
|
|
418
|
+
frames = []
|
|
419
|
+
if frames:
|
|
420
|
+
yield first_frame, np.stack(frames, axis=0)
|
|
421
|
+
|
|
422
|
+
def _block_frames(self, height: int, width: int) -> int:
|
|
423
|
+
"""Frames per processing block: bounded in memory, large enough to feed all workers."""
|
|
424
|
+
workers = self.config.locate_workers or (os.cpu_count() or 1)
|
|
425
|
+
bytes_per_pixel = self.config.processing_bit_depth // 8
|
|
426
|
+
by_memory = max(1, int(128e6 // (height * width * bytes_per_pixel)))
|
|
427
|
+
frames = min(self.effective_chunk_frames, by_memory)
|
|
428
|
+
return max(frames, workers * 4 if workers > 1 else 1)
|
|
429
|
+
|
|
430
|
+
def _iter_blocks(self):
|
|
431
|
+
"""Yield (first frame, raw ROI block); served from RAM once the stack is resident."""
|
|
432
|
+
_, _, height, width = self._source_range_and_shape()
|
|
433
|
+
size = self._block_frames(height, width)
|
|
434
|
+
if self._stack is not None:
|
|
435
|
+
for start in range(0, len(self._stack), size):
|
|
436
|
+
yield self._stack_first + start, self._stack[start : start + size]
|
|
437
|
+
return
|
|
438
|
+
yield from self._iter_source_blocks(size)
|
|
439
|
+
|
|
440
|
+
def _require_store(self) -> Path:
|
|
441
|
+
if self.store is None:
|
|
442
|
+
raise RuntimeError("The chunk store has not been prepared")
|
|
443
|
+
return self.store
|
|
444
|
+
|
|
445
|
+
def _global_background(self) -> np.ndarray:
|
|
446
|
+
"""Estimate the 1.0 background: per-pixel 30-70% mean over all frames.
|
|
447
|
+
|
|
448
|
+
The ROI stack is read once into a preallocated array at the selected
|
|
449
|
+
processing bit depth, kept for later passes, and reduced in pixel
|
|
450
|
+
strips.
|
|
451
|
+
"""
|
|
452
|
+
first, stop, height, width = self._source_range_and_shape()
|
|
453
|
+
frames = stop - first
|
|
454
|
+
stack_dtype = (
|
|
455
|
+
np.uint8 if self.config.processing_bit_depth == 8 else np.uint16
|
|
456
|
+
)
|
|
457
|
+
needed = frames * height * width * np.dtype(stack_dtype).itemsize
|
|
458
|
+
if self.config.background_stack_storage == "disk":
|
|
459
|
+
self.config.output_dir.mkdir(parents=True, exist_ok=True)
|
|
460
|
+
free_space = shutil.disk_usage(self.config.output_dir).free
|
|
461
|
+
if needed > free_space:
|
|
462
|
+
raise OSError(
|
|
463
|
+
"Disk-backed background stack needs "
|
|
464
|
+
f"{needed / 1e9:.1f} GB but only "
|
|
465
|
+
f"{free_space / 1e9:.1f} GB is available in "
|
|
466
|
+
f"{self.config.output_dir}."
|
|
467
|
+
)
|
|
468
|
+
self._background_stack_file = tempfile.NamedTemporaryFile(
|
|
469
|
+
mode="w+b",
|
|
470
|
+
prefix=".background-stack-",
|
|
471
|
+
suffix=f".{np.dtype(stack_dtype).name}",
|
|
472
|
+
dir=self.config.output_dir,
|
|
473
|
+
)
|
|
474
|
+
self._background_stack_file.truncate(needed)
|
|
475
|
+
stack = np.memmap(
|
|
476
|
+
self._background_stack_file,
|
|
477
|
+
dtype=stack_dtype,
|
|
478
|
+
mode="w+",
|
|
479
|
+
shape=(frames, height, width),
|
|
480
|
+
)
|
|
481
|
+
else:
|
|
482
|
+
available = _available_memory_bytes()
|
|
483
|
+
if available is not None and needed > 0.8 * available:
|
|
484
|
+
raise MemoryError(
|
|
485
|
+
f"The exact 1.0 background needs the whole ROI stack in RAM "
|
|
486
|
+
f"({needed / 1e9:.1f} GB) but only "
|
|
487
|
+
f"{available / 1e9:.1f} GB is available. Use "
|
|
488
|
+
"background_stack_storage='disk' or reduce the ROI or frame range."
|
|
489
|
+
)
|
|
490
|
+
stack = np.empty((frames, height, width), dtype=stack_dtype)
|
|
491
|
+
self._stack = stack
|
|
492
|
+
filled = 0
|
|
493
|
+
for block_first, block in progress(
|
|
494
|
+
self._iter_source_blocks(self._block_frames(height, width)),
|
|
495
|
+
desc="Reading frames",
|
|
496
|
+
unit="block",
|
|
497
|
+
):
|
|
498
|
+
# Chunks may overlap; count every frame once
|
|
499
|
+
skip = max(0, first + filled - block_first)
|
|
500
|
+
block = block[skip:]
|
|
501
|
+
block = block[: frames - filled]
|
|
502
|
+
if len(block):
|
|
503
|
+
stack[filled : filled + len(block)] = block
|
|
504
|
+
filled += len(block)
|
|
505
|
+
if filled != frames:
|
|
506
|
+
raise RuntimeError(f"Expected {frames} frames but read {filled}")
|
|
507
|
+
self._stack_first = first
|
|
508
|
+
offset = float(stack.min())
|
|
509
|
+
flat = stack.reshape(frames, height * width)
|
|
510
|
+
low = int(0.3 * frames)
|
|
511
|
+
high = max(low + 1, int(0.7 * frames))
|
|
512
|
+
background = np.empty(height * width, dtype=np.float64)
|
|
513
|
+
strip = max(1, int(2**28 // (frames * flat.itemsize)))
|
|
514
|
+
for start in range(0, height * width, strip):
|
|
515
|
+
part = np.ascontiguousarray(flat[:, start : start + strip].T)
|
|
516
|
+
if self.config.background_method == "median":
|
|
517
|
+
background[start : start + strip] = np.median(part, axis=1)
|
|
518
|
+
else:
|
|
519
|
+
part.partition([low, high - 1], axis=1)
|
|
520
|
+
background[start : start + strip] = part[:, low:high].mean(
|
|
521
|
+
axis=1, dtype=np.float64
|
|
522
|
+
)
|
|
523
|
+
background = background.reshape(height, width)
|
|
524
|
+
if self.config.remove_camera_offset:
|
|
525
|
+
self.global_offset = offset
|
|
526
|
+
background = background - offset
|
|
527
|
+
global_background = background.astype(np.float32)
|
|
528
|
+
np.save(self.config.output_dir / "global_background.npy", global_background)
|
|
529
|
+
update(f"Global background estimated from {frames} frames")
|
|
530
|
+
return global_background
|
|
531
|
+
|
|
532
|
+
def _global_processed_max(self) -> float:
|
|
533
|
+
"""Determine one intensity scale for all blocks, matching 1.0 scaling."""
|
|
534
|
+
maximum = 0.0
|
|
535
|
+
for _, raw_block in self._iter_blocks():
|
|
536
|
+
processed = preprocess(
|
|
537
|
+
raw_block,
|
|
538
|
+
self.config.remove_camera_offset,
|
|
539
|
+
self.config.remove_laser_fluctuation,
|
|
540
|
+
self.config.remove_static_background,
|
|
541
|
+
self.config.background_method,
|
|
542
|
+
self.config.enhance_snr,
|
|
543
|
+
background=self.global_background,
|
|
544
|
+
offset=self.global_offset,
|
|
545
|
+
snr_sigma_px=self.config.effective_snr_sigma_px,
|
|
546
|
+
)
|
|
547
|
+
maximum = max(maximum, float(np.max(np.maximum(processed, 0.0))))
|
|
548
|
+
if maximum <= 0:
|
|
549
|
+
raise RuntimeError("Processed image stack contains no positive intensity")
|
|
550
|
+
return maximum
|
|
551
|
+
|
|
552
|
+
@property
|
|
553
|
+
def _kept_minimum_frames(self) -> int:
|
|
554
|
+
"""Shortest track kept: the valid-track minimum, never above the drift minimum."""
|
|
555
|
+
return min(
|
|
556
|
+
self.config.minimum_trajectory_frames,
|
|
557
|
+
self.config.minimum_trajectory_frames_before_drift,
|
|
558
|
+
)
|
|
559
|
+
|
|
560
|
+
def track(
|
|
561
|
+
self,
|
|
562
|
+
detections: pd.DataFrame,
|
|
563
|
+
search_range: float,
|
|
564
|
+
memory: int = 1,
|
|
565
|
+
minimum_frames: int | None = None,
|
|
566
|
+
) -> tuple[pd.DataFrame, Path]:
|
|
567
|
+
"""Link the compact detection table after blockwise localization."""
|
|
568
|
+
update("Step 3/4: Linking trajectories")
|
|
569
|
+
minimum_frames = (
|
|
570
|
+
self.config.minimum_trajectory_frames
|
|
571
|
+
if minimum_frames is None
|
|
572
|
+
else minimum_frames
|
|
573
|
+
)
|
|
574
|
+
single = self.config.single_particle
|
|
575
|
+
moving_detections = detections
|
|
576
|
+
stationary = pd.DataFrame()
|
|
577
|
+
if self.config.analyze_stationary_particles:
|
|
578
|
+
stationary = link(
|
|
579
|
+
detections,
|
|
580
|
+
search_range=self.config.stationary_search_range,
|
|
581
|
+
memory=1,
|
|
582
|
+
minimum_frames=self.config.stationary_minimum_frames,
|
|
583
|
+
)
|
|
584
|
+
moving_detections = remove_near_stationary(
|
|
585
|
+
detections, stationary, self.config.no_go_distance
|
|
586
|
+
)
|
|
587
|
+
stationary.to_csv(self.config.output_dir / "stationary_trajectories.csv", index=False)
|
|
588
|
+
trajectories = link(
|
|
589
|
+
moving_detections,
|
|
590
|
+
search_range=search_range,
|
|
591
|
+
memory=memory,
|
|
592
|
+
minimum_frames=1 if single else self._kept_minimum_frames,
|
|
593
|
+
)
|
|
594
|
+
if single:
|
|
595
|
+
config = self.config
|
|
596
|
+
fragments = trajectories["particle"].nunique()
|
|
597
|
+
trajectories = stitch_fragments(
|
|
598
|
+
trajectories,
|
|
599
|
+
config.stitch_max_gap_frames,
|
|
600
|
+
config.stitch_max_distance_px,
|
|
601
|
+
config.stitch_distance_growth_px,
|
|
602
|
+
config.stitch_max_mass_ratio,
|
|
603
|
+
config.stitch_min_fragment_frames,
|
|
604
|
+
)
|
|
605
|
+
stitched = trajectories["particle"].nunique()
|
|
606
|
+
if config.single_particle_keep != "all":
|
|
607
|
+
trajectories = keep_dominant_particle(trajectories, config.single_particle_keep)
|
|
608
|
+
update(
|
|
609
|
+
f"Single particle mode: {fragments} fragments stitched into {stitched} "
|
|
610
|
+
f"trajectories, {trajectories['particle'].nunique()} kept"
|
|
611
|
+
)
|
|
612
|
+
trajectories = filter_minimum_frames(
|
|
613
|
+
trajectories, self._kept_minimum_frames
|
|
614
|
+
)
|
|
615
|
+
if self.config.split_intensity_jumps:
|
|
616
|
+
trajectories = split_intensity_jumps(
|
|
617
|
+
trajectories,
|
|
618
|
+
self.config.max_relative_intensity_step,
|
|
619
|
+
self._kept_minimum_frames,
|
|
620
|
+
)
|
|
621
|
+
trajectories = split_long_trajectories(
|
|
622
|
+
trajectories,
|
|
623
|
+
self.config.maximum_trajectory_frames,
|
|
624
|
+
self._kept_minimum_frames,
|
|
625
|
+
self.config.keep_short_trajectory_tail,
|
|
626
|
+
)
|
|
627
|
+
path = self.config.output_dir / "trajectories.csv"
|
|
628
|
+
trajectories.to_csv(path, index=False)
|
|
629
|
+
return trajectories, path
|
|
630
|
+
|
|
631
|
+
def analyze_trajectories(
|
|
632
|
+
self,
|
|
633
|
+
trajectories: pd.DataFrame,
|
|
634
|
+
dimension: str = "2d",
|
|
635
|
+
max_lag: int | None = None,
|
|
636
|
+
drift_window: int = 20,
|
|
637
|
+
min_particles_for_drift: int = 2,
|
|
638
|
+
output_suffix: str = "",
|
|
639
|
+
) -> tuple[pd.DataFrame, Path]:
|
|
640
|
+
"""Correct drift, estimate diffusion, and calculate particle sizes."""
|
|
641
|
+
update("Step 4/4: Correcting drift, estimating MSD, and sizing particles")
|
|
642
|
+
# With one particle the "common drift" would just be its own motion
|
|
643
|
+
if self.config.drift_enabled and not self.config.single_particle:
|
|
644
|
+
corrected, drift = correct_global(
|
|
645
|
+
trajectories,
|
|
646
|
+
drift_window,
|
|
647
|
+
min_particles_for_drift,
|
|
648
|
+
self.config.minimum_trajectory_frames_before_drift,
|
|
649
|
+
)
|
|
650
|
+
else:
|
|
651
|
+
corrected = trajectories.copy()
|
|
652
|
+
corrected["x_corrected"] = corrected["x"]
|
|
653
|
+
corrected["y_corrected"] = corrected["y"]
|
|
654
|
+
drift = pd.DataFrame(
|
|
655
|
+
{"frame": sorted(corrected["frame"].unique()), "drift_x": 0.0, "drift_y": 0.0}
|
|
656
|
+
)
|
|
657
|
+
if dimension == "x":
|
|
658
|
+
legacy_trajectories(corrected).to_csv(
|
|
659
|
+
self.config.output_dir / "traj_no_drift.csv", index=False
|
|
660
|
+
)
|
|
661
|
+
corrected = filter_minimum_frames(
|
|
662
|
+
corrected, self.config.minimum_trajectory_frames
|
|
663
|
+
)
|
|
664
|
+
if "saturated" in corrected:
|
|
665
|
+
# Saturated positions are poorly localized; the legacy fit ignores them
|
|
666
|
+
corrected = filter_minimum_frames(
|
|
667
|
+
corrected[~corrected["saturated"].astype(bool)],
|
|
668
|
+
self.config.minimum_trajectory_frames,
|
|
669
|
+
)
|
|
670
|
+
quality = pd.DataFrame()
|
|
671
|
+
if self.config.apply_quality_filter:
|
|
672
|
+
corrected, quality = filter_trajectories(
|
|
673
|
+
corrected,
|
|
674
|
+
dimension=dimension,
|
|
675
|
+
max_lag=(
|
|
676
|
+
self.config.msd_max_lag_frames
|
|
677
|
+
if max_lag is None
|
|
678
|
+
else max_lag
|
|
679
|
+
),
|
|
680
|
+
pixel_size_um=self.config.pixel_size_um,
|
|
681
|
+
fps=self.config.fps,
|
|
682
|
+
minimum_pairs_at_max_lag=self.config.minimum_msd_pairs_at_max_lag,
|
|
683
|
+
minimum_ks_significance=self.config.minimum_ks_significance,
|
|
684
|
+
ks_method=self.config.ks_method,
|
|
685
|
+
)
|
|
686
|
+
quality.to_csv(
|
|
687
|
+
self.config.output_dir / f"trajectory_quality_{dimension}.csv",
|
|
688
|
+
index=False,
|
|
689
|
+
)
|
|
690
|
+
if dimension == "x":
|
|
691
|
+
quality.to_csv(
|
|
692
|
+
self.config.output_dir / "trajectory_quality.csv",
|
|
693
|
+
index=False,
|
|
694
|
+
)
|
|
695
|
+
else:
|
|
696
|
+
quality.to_csv(
|
|
697
|
+
self.config.output_dir / f"trajectory_quality_{dimension}.csv",
|
|
698
|
+
index=False,
|
|
699
|
+
)
|
|
700
|
+
if dimension == "x":
|
|
701
|
+
quality.to_csv(
|
|
702
|
+
self.config.output_dir / "trajectory_quality.csv",
|
|
703
|
+
index=False,
|
|
704
|
+
)
|
|
705
|
+
estimates = estimate_all(
|
|
706
|
+
corrected,
|
|
707
|
+
dimension=dimension,
|
|
708
|
+
max_lag=(
|
|
709
|
+
self.config.msd_max_lag_frames
|
|
710
|
+
if max_lag is None
|
|
711
|
+
else max_lag
|
|
712
|
+
),
|
|
713
|
+
pixel_size_um=self.config.pixel_size_um,
|
|
714
|
+
fps=self.config.fps,
|
|
715
|
+
auto_lag=self.config.msd_lag_auto and max_lag is None,
|
|
716
|
+
fixed_red_x=(
|
|
717
|
+
red_x_from_theory(
|
|
718
|
+
self.config.exposure_time_s,
|
|
719
|
+
1.0 / self.config.fps,
|
|
720
|
+
self.config.numerical_aperture,
|
|
721
|
+
self.config.wavelength_nm,
|
|
722
|
+
)
|
|
723
|
+
if self.config.red_x_mode == "theory"
|
|
724
|
+
else None
|
|
725
|
+
),
|
|
726
|
+
)
|
|
727
|
+
if estimates.empty:
|
|
728
|
+
rejected = (
|
|
729
|
+
quality["reason"].value_counts().to_dict()
|
|
730
|
+
if "reason" in quality
|
|
731
|
+
else {}
|
|
732
|
+
)
|
|
733
|
+
update(
|
|
734
|
+
f"No trajectory survived the filters for dimension '{dimension}' "
|
|
735
|
+
f"(rejection reasons: {rejected or 'none recorded'}); skipping sizing and plots"
|
|
736
|
+
)
|
|
737
|
+
suffix = f"_{output_suffix}" if output_suffix else ""
|
|
738
|
+
path = self.config.output_dir / f"particle_sizes{suffix}.csv"
|
|
739
|
+
estimates.to_csv(path, index=False)
|
|
740
|
+
self.plot_paths = {}
|
|
741
|
+
return estimates, path
|
|
742
|
+
local = {}
|
|
743
|
+
if self.config.hindrance_mode == "position":
|
|
744
|
+
center = self.resolve_channel_center()
|
|
745
|
+
if center is None or self.config.channel_diameter_nm is None:
|
|
746
|
+
raise RuntimeError(
|
|
747
|
+
"Position-dependent hindrance correction needs a channel center "
|
|
748
|
+
"and channel diameter"
|
|
749
|
+
)
|
|
750
|
+
for particle, group in corrected.groupby("particle"):
|
|
751
|
+
local[particle] = local_channel_diameter_nm(
|
|
752
|
+
group["y_corrected"].to_numpy(),
|
|
753
|
+
center,
|
|
754
|
+
self.config.channel_diameter_nm,
|
|
755
|
+
self.config.pixel_size_um,
|
|
756
|
+
)
|
|
757
|
+
estimates["local_channel_diameter_nm"] = estimates["particle"].map(local)
|
|
758
|
+
elif self.config.hindrance_mode == "legacy":
|
|
759
|
+
estimates["local_channel_diameter_nm"] = self.config.channel_diameter_nm
|
|
760
|
+
diameters = []
|
|
761
|
+
for particle, diffusion in zip(
|
|
762
|
+
estimates["particle"], estimates["diffusion_um2_s"]
|
|
763
|
+
):
|
|
764
|
+
if not pd.notna(diffusion) or diffusion <= 0:
|
|
765
|
+
diameters.append(float("nan"))
|
|
766
|
+
continue
|
|
767
|
+
channel_diameter = (
|
|
768
|
+
local.get(particle)
|
|
769
|
+
if self.config.hindrance_mode == "position"
|
|
770
|
+
else self.config.channel_diameter_nm
|
|
771
|
+
if self.config.hindrance_mode == "legacy"
|
|
772
|
+
else None
|
|
773
|
+
)
|
|
774
|
+
diameters.append(
|
|
775
|
+
corrected_diameter_nm(
|
|
776
|
+
diffusion,
|
|
777
|
+
self.config.temperature_k,
|
|
778
|
+
self.config.viscosity_pa_s,
|
|
779
|
+
channel_diameter,
|
|
780
|
+
)
|
|
781
|
+
)
|
|
782
|
+
estimates["diameter_nm"] = diameters
|
|
783
|
+
if self.config.maximum_diameter_nm is not None:
|
|
784
|
+
too_large = estimates["diameter_nm"] > self.config.maximum_diameter_nm
|
|
785
|
+
estimates["diameter_filter_reason"] = ""
|
|
786
|
+
estimates.loc[too_large, "diameter_filter_reason"] = (
|
|
787
|
+
f"above maximum_diameter_nm={self.config.maximum_diameter_nm:g}"
|
|
788
|
+
)
|
|
789
|
+
estimates.loc[too_large, "diameter_nm"] = float("nan")
|
|
790
|
+
else:
|
|
791
|
+
estimates["diameter_filter_reason"] = ""
|
|
792
|
+
if self.config.force_ultra_uniform:
|
|
793
|
+
outliers = ~ultra_uniform_mask(
|
|
794
|
+
estimates, self.config.ultra_uniform_z_max
|
|
795
|
+
)
|
|
796
|
+
estimates.loc[outliers, "diameter_filter_reason"] = (
|
|
797
|
+
f"ultra-uniform z-score >= {self.config.ultra_uniform_z_max:g}"
|
|
798
|
+
)
|
|
799
|
+
estimates.loc[outliers, "diameter_nm"] = float("nan")
|
|
800
|
+
suffix = f"_{output_suffix}" if output_suffix else ""
|
|
801
|
+
path = self.config.output_dir / f"particle_sizes{suffix}.csv"
|
|
802
|
+
estimates.to_csv(path, index=False)
|
|
803
|
+
summary = diameter_summary(estimates)
|
|
804
|
+
summary.to_csv(
|
|
805
|
+
self.config.output_dir / f"particle_size_summary{suffix}.csv",
|
|
806
|
+
index=False,
|
|
807
|
+
)
|
|
808
|
+
if output_suffix == "x":
|
|
809
|
+
estimates.to_csv(self.config.output_dir / "particle_sizes.csv", index=False)
|
|
810
|
+
summary.to_csv(
|
|
811
|
+
self.config.output_dir / "particle_size_summary.csv",
|
|
812
|
+
index=False,
|
|
813
|
+
)
|
|
814
|
+
drift.to_csv(self.config.output_dir / f"drift_{dimension}.csv", index=False)
|
|
815
|
+
if dimension == "x":
|
|
816
|
+
drift.to_csv(self.config.output_dir / "drift.csv", index=False)
|
|
817
|
+
corrected_path = self.config.output_dir / f"trajectories_corrected{suffix}.csv"
|
|
818
|
+
corrected.to_csv(corrected_path, index=False)
|
|
819
|
+
if output_suffix == "x":
|
|
820
|
+
corrected.to_csv(
|
|
821
|
+
self.config.output_dir / "trajectories_corrected.csv",
|
|
822
|
+
index=False,
|
|
823
|
+
)
|
|
824
|
+
label = {"x": "long", "y": "trans"}.get(dimension, dimension)
|
|
825
|
+
sizes_table = legacy_sizes_table(
|
|
826
|
+
corrected, trajectories, estimates, quality, self.config.fps
|
|
827
|
+
)
|
|
828
|
+
for weighting in ("unweighted", "weighted"):
|
|
829
|
+
sizes_table.to_csv(
|
|
830
|
+
self.config.output_dir
|
|
831
|
+
/ f"Diameter_Histogramm-{label}-{weighting}.csv",
|
|
832
|
+
index=False,
|
|
833
|
+
)
|
|
834
|
+
final = legacy_trajectories(corrected)
|
|
835
|
+
final = final[final["particle"].isin(sizes_table["particle"])]
|
|
836
|
+
sizes_table.to_csv(
|
|
837
|
+
self.config.output_dir / f"sizes_df_lin_{label}.csv", index=False
|
|
838
|
+
)
|
|
839
|
+
sizes_table.to_csv(
|
|
840
|
+
self.config.output_dir / f"DiameterOverTrajLength_{label}.csv", index=False
|
|
841
|
+
)
|
|
842
|
+
final.to_csv(self.config.output_dir / f"traj_final_{label}.csv", index=False)
|
|
843
|
+
if dimension == "x":
|
|
844
|
+
final.to_csv(self.config.output_dir / "traj_final.csv", index=False)
|
|
845
|
+
plot_paths = create_analysis_plots(
|
|
846
|
+
corrected,
|
|
847
|
+
estimates,
|
|
848
|
+
self.config.output_dir,
|
|
849
|
+
pixel_size_um=self.config.pixel_size_um,
|
|
850
|
+
fps=self.config.fps,
|
|
851
|
+
dimension=dimension,
|
|
852
|
+
max_lag=(
|
|
853
|
+
self.config.msd_max_lag_frames
|
|
854
|
+
if max_lag is None
|
|
855
|
+
else max_lag
|
|
856
|
+
),
|
|
857
|
+
dimension_label=output_suffix or dimension,
|
|
858
|
+
drift=drift,
|
|
859
|
+
)
|
|
860
|
+
self.plot_paths = plot_paths
|
|
861
|
+
return estimates, path
|
|
862
|
+
|
|
863
|
+
def prepare(self) -> dict:
|
|
864
|
+
manifest = self.prepare_store()
|
|
865
|
+
center = self.resolve_channel_center()
|
|
866
|
+
return {
|
|
867
|
+
"tiff": tiff_info(self.config.input_file),
|
|
868
|
+
"chunked_shape": tuple(manifest["shape"]),
|
|
869
|
+
"chunk_count": len(manifest["chunks"]),
|
|
870
|
+
"chunk_store": (
|
|
871
|
+
str(self.store.resolve()) if self.store is not None else None
|
|
872
|
+
),
|
|
873
|
+
"channel_center_y_px": center,
|
|
874
|
+
}
|