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.
@@ -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
+ }