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,177 @@
|
|
|
1
|
+
"""Automatic detection parameters ported from 1.0 (PSF width, trackpy diameter, minmass)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
from scipy import ndimage
|
|
8
|
+
from scipy.signal import fftconvolve
|
|
9
|
+
from scipy.spatial import cKDTree
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def sigma_psf_um(numerical_aperture: float, wavelength_nm: float) -> float:
|
|
13
|
+
"""Gaussian approximation of the PSF width in µm (Zhang et al. 2007, as in 1.0)."""
|
|
14
|
+
return 0.21 * wavelength_nm / numerical_aperture / 1000.0
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def sigma_psf_px(
|
|
18
|
+
numerical_aperture: float, wavelength_nm: float, pixel_size_um: float
|
|
19
|
+
) -> float:
|
|
20
|
+
return sigma_psf_um(numerical_aperture, wavelength_nm) / pixel_size_um
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def trackpy_diameter(
|
|
24
|
+
numerical_aperture: float,
|
|
25
|
+
wavelength_nm: float,
|
|
26
|
+
pixel_size_um: float,
|
|
27
|
+
enhance_snr: bool,
|
|
28
|
+
) -> int:
|
|
29
|
+
"""Odd trackpy feature diameter covering the 2-sigma interval of the PSF."""
|
|
30
|
+
diameter_px = 4.0 * sigma_psf_px(numerical_aperture, wavelength_nm, pixel_size_um)
|
|
31
|
+
if enhance_snr:
|
|
32
|
+
# Convolving the PSF with a Gaussian of equal width widens it by sqrt(2)
|
|
33
|
+
diameter_px *= np.sqrt(2.0)
|
|
34
|
+
diameter = int(np.ceil(diameter_px))
|
|
35
|
+
return diameter + 1 if diameter % 2 == 0 else diameter
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def red_x_from_theory(
|
|
39
|
+
exposure_time_s: float,
|
|
40
|
+
lagtime_s: float,
|
|
41
|
+
numerical_aperture: float,
|
|
42
|
+
wavelength_nm: float,
|
|
43
|
+
diffusion_um2_s: float = 1.0,
|
|
44
|
+
photons: float = 1.0,
|
|
45
|
+
) -> float:
|
|
46
|
+
"""Reduced squared localization error from theory (Michalet & Berglund 2012).
|
|
47
|
+
|
|
48
|
+
Mirrors 1.0, which evaluates it with D = 1 µm²/s and one photon.
|
|
49
|
+
"""
|
|
50
|
+
blur = exposure_time_s / lagtime_s / 6.0
|
|
51
|
+
s0 = sigma_psf_um(numerical_aperture, wavelength_nm)
|
|
52
|
+
sigma0 = s0 / np.sqrt(photons)
|
|
53
|
+
sigma = sigma0 * np.sqrt(1.0 + diffusion_um2_s * exposure_time_s / s0**2)
|
|
54
|
+
return float(sigma**2 / (diffusion_um2_s * lagtime_s) - 2.0 * blur)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def gaussian_kernel(sigma: float, factor: float = 6.0) -> np.ndarray:
|
|
58
|
+
size = int(np.ceil(factor * 2 * sigma))
|
|
59
|
+
size += 1 - size % 2
|
|
60
|
+
radius = (size - 1) // 2
|
|
61
|
+
y, x = np.mgrid[-radius : radius + 1, -radius : radius + 1]
|
|
62
|
+
kernel = np.exp(-(x**2 + y**2) / (2.0 * sigma**2))
|
|
63
|
+
return kernel / kernel.sum()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def zncc_image(frame: np.ndarray, kernel: np.ndarray) -> np.ndarray:
|
|
67
|
+
"""Zero-normalised cross-correlation of a frame with a kernel for every pixel.
|
|
68
|
+
|
|
69
|
+
Uses the identity zncc = sum(p * (k - mean k)) / (N * std(p) * std(k)), so the
|
|
70
|
+
whole image is handled by one FFT correlation and two box filters. Pixels whose
|
|
71
|
+
window leaves the image are 0, as in 1.0.
|
|
72
|
+
"""
|
|
73
|
+
frame = np.asarray(frame, dtype=np.float64)
|
|
74
|
+
size = kernel.shape[0]
|
|
75
|
+
count = float(size * size)
|
|
76
|
+
centered = kernel - kernel.mean()
|
|
77
|
+
kernel_std = np.sqrt(np.mean(centered**2))
|
|
78
|
+
numerator = fftconvolve(frame, centered[::-1, ::-1], mode="same") / count
|
|
79
|
+
local_mean = ndimage.uniform_filter(frame, size, mode="constant")
|
|
80
|
+
local_square = ndimage.uniform_filter(frame**2, size, mode="constant")
|
|
81
|
+
local_std = np.sqrt(np.maximum(local_square - local_mean**2, 0.0))
|
|
82
|
+
denominator = local_std * kernel_std
|
|
83
|
+
result = np.divide(
|
|
84
|
+
numerator, denominator, out=np.zeros_like(frame), where=denominator > 1e-9
|
|
85
|
+
)
|
|
86
|
+
margin = (size - 1) // 2
|
|
87
|
+
result[:margin] = 0
|
|
88
|
+
result[-margin:] = 0
|
|
89
|
+
result[:, :margin] = 0
|
|
90
|
+
result[:, -margin:] = 0
|
|
91
|
+
return result
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def zncc_particles(
|
|
95
|
+
frames: np.ndarray,
|
|
96
|
+
sigma_px: float,
|
|
97
|
+
enhance_snr: bool,
|
|
98
|
+
minimum_correlation: float,
|
|
99
|
+
) -> pd.DataFrame:
|
|
100
|
+
"""Locate particles by ZNCC with the PSF; returns y, x, frame (local index)."""
|
|
101
|
+
if enhance_snr:
|
|
102
|
+
kernel = gaussian_kernel(sigma_px * np.sqrt(2.0), factor=10.0)
|
|
103
|
+
else:
|
|
104
|
+
kernel = gaussian_kernel(sigma_px)
|
|
105
|
+
structure = ndimage.generate_binary_structure(2, 2)
|
|
106
|
+
rows = []
|
|
107
|
+
for index, frame in enumerate(frames):
|
|
108
|
+
frame = np.maximum(np.asarray(frame, dtype=np.float64), 0.0)
|
|
109
|
+
if enhance_snr:
|
|
110
|
+
frame = ndimage.gaussian_filter(frame, sigma_px)
|
|
111
|
+
labels, count = ndimage.label(
|
|
112
|
+
zncc_image(frame, kernel) > minimum_correlation, structure=structure
|
|
113
|
+
)
|
|
114
|
+
if count:
|
|
115
|
+
centers = ndimage.center_of_mass(np.ones_like(labels), labels, range(1, count + 1))
|
|
116
|
+
rows.extend((int(y), int(x), index) for y, x in centers)
|
|
117
|
+
return pd.DataFrame(rows, columns=["y", "x", "frame"])
|
|
118
|
+
|
|
119
|
+
|
|
120
|
+
def optimize_minmass(
|
|
121
|
+
candidates: pd.DataFrame,
|
|
122
|
+
reference: pd.DataFrame,
|
|
123
|
+
max_distance: float,
|
|
124
|
+
max_iterations: int = 100000,
|
|
125
|
+
) -> tuple[float, pd.DataFrame]:
|
|
126
|
+
"""Raise minmass until trackpy detections match the ZNCC reference best (1.0 algorithm).
|
|
127
|
+
|
|
128
|
+
``candidates`` are trackpy detections found with minmass=1; raising the threshold
|
|
129
|
+
is then a simple mass filter. Returns the optimum (before the 0.9 safety factor)
|
|
130
|
+
and the history of the search.
|
|
131
|
+
"""
|
|
132
|
+
reference_points = {
|
|
133
|
+
frame: group[["y", "x"]].to_numpy(float) for frame, group in reference.groupby("frame")
|
|
134
|
+
}
|
|
135
|
+
by_frame = {frame: group for frame, group in candidates.groupby("frame")}
|
|
136
|
+
n_reference = len(reference)
|
|
137
|
+
minmass = 1
|
|
138
|
+
history = []
|
|
139
|
+
first = True
|
|
140
|
+
for _ in range(max_iterations):
|
|
141
|
+
trees = {}
|
|
142
|
+
n_found = 0
|
|
143
|
+
for frame, group in by_frame.items():
|
|
144
|
+
kept = group[group["mass"] > minmass]
|
|
145
|
+
n_found += len(kept)
|
|
146
|
+
if len(kept):
|
|
147
|
+
trees[frame] = cKDTree(kept[["y", "x"]].to_numpy(float))
|
|
148
|
+
if first and n_found < n_reference:
|
|
149
|
+
raise RuntimeError(
|
|
150
|
+
"Trackpy finds fewer particles than the ZNCC reference even at minmass=1. "
|
|
151
|
+
"The sample may be too dense, or tp_percentile too aggressive."
|
|
152
|
+
)
|
|
153
|
+
first = False
|
|
154
|
+
if n_found > 5 * n_reference:
|
|
155
|
+
minmass = int(minmass * 1.2) + 10
|
|
156
|
+
continue
|
|
157
|
+
if n_found > 2 * n_reference:
|
|
158
|
+
minmass = int(minmass * 1.1) + 10
|
|
159
|
+
continue
|
|
160
|
+
right = 0
|
|
161
|
+
wrong = max(n_found - n_reference, 0)
|
|
162
|
+
for frame, points in reference_points.items():
|
|
163
|
+
tree = trees.get(frame)
|
|
164
|
+
if tree is None:
|
|
165
|
+
wrong += len(points)
|
|
166
|
+
continue
|
|
167
|
+
distance, _ = tree.query(points)
|
|
168
|
+
hits = int(np.sum(distance <= max_distance))
|
|
169
|
+
right += hits
|
|
170
|
+
wrong += len(points) - hits
|
|
171
|
+
ratio = wrong / right if right else np.inf
|
|
172
|
+
history.append((minmass, right, wrong, ratio))
|
|
173
|
+
if wrong == 0 or n_found < 0.8 * n_reference:
|
|
174
|
+
break
|
|
175
|
+
minmass = int(minmass * 1.02) + 1
|
|
176
|
+
table = pd.DataFrame(history, columns=["minmass", "right_found", "wrong_found", "wrong_to_right"])
|
|
177
|
+
return float(table.loc[table["wrong_to_right"].idxmin(), "minmass"]), table
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Automatic geometric calibration from the image stack."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
from .storage import read_frames
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def estimate_channel_center_y(
|
|
13
|
+
frames: np.ndarray,
|
|
14
|
+
intensity_percentile: float = 99.5,
|
|
15
|
+
) -> float:
|
|
16
|
+
"""Estimate the transverse channel center from a robust intensity profile.
|
|
17
|
+
|
|
18
|
+
The high spatial percentile preserves the illuminated channel while the
|
|
19
|
+
median over time suppresses moving particles. The center is the weighted
|
|
20
|
+
centroid of the contiguous signal region above a conservative threshold.
|
|
21
|
+
"""
|
|
22
|
+
data = np.asarray(frames, dtype=np.float32)
|
|
23
|
+
if data.ndim != 3 or data.shape[1] < 3:
|
|
24
|
+
raise ValueError("frames must have shape (frames, y, x)")
|
|
25
|
+
if not 90.0 <= intensity_percentile < 100.0:
|
|
26
|
+
raise ValueError("intensity_percentile must be in [90, 100)")
|
|
27
|
+
|
|
28
|
+
profile = np.median(
|
|
29
|
+
np.percentile(data, intensity_percentile, axis=2), axis=0
|
|
30
|
+
)
|
|
31
|
+
baseline = float(np.percentile(profile, 20))
|
|
32
|
+
peak = float(np.max(profile))
|
|
33
|
+
excess = profile - baseline
|
|
34
|
+
if peak <= baseline or not np.isfinite(peak):
|
|
35
|
+
raise ValueError("No channel signal found in transverse profile")
|
|
36
|
+
|
|
37
|
+
threshold = baseline + 0.30 * (peak - baseline)
|
|
38
|
+
active = profile >= threshold
|
|
39
|
+
peak_index = int(np.argmax(profile))
|
|
40
|
+
left = peak_index
|
|
41
|
+
right = peak_index
|
|
42
|
+
while left > 0 and active[left - 1]:
|
|
43
|
+
left -= 1
|
|
44
|
+
while right + 1 < len(active) and active[right + 1]:
|
|
45
|
+
right += 1
|
|
46
|
+
weights = np.maximum(excess[left : right + 1], 0)
|
|
47
|
+
positions = np.arange(left, right + 1, dtype=float)
|
|
48
|
+
# The midpoint is less sensitive to a few bright particles than a
|
|
49
|
+
# brightness-weighted centroid when the illumination is asymmetric.
|
|
50
|
+
return float((left + right) / 2.0)
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def estimate_channel_center_from_tiff(
|
|
54
|
+
path: str | Path,
|
|
55
|
+
frame_start: int = 0,
|
|
56
|
+
frame_stop: int | None = 300,
|
|
57
|
+
intensity_percentile: float = 99.5,
|
|
58
|
+
) -> float:
|
|
59
|
+
"""Estimate the channel center from a bounded TIFF sample."""
|
|
60
|
+
stop = frame_start + 300 if frame_stop is None else frame_stop
|
|
61
|
+
return estimate_channel_center_y(
|
|
62
|
+
read_frames(Path(path), frame_start, stop),
|
|
63
|
+
intensity_percentile=intensity_percentile,
|
|
64
|
+
)
|
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
"""Configuration for the independent analysis pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .auto_parameters import sigma_psf_px
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@dataclass(frozen=True)
|
|
12
|
+
class Roi:
|
|
13
|
+
x_min: int = 0
|
|
14
|
+
x_max: int | None = None
|
|
15
|
+
y_min: int = 0
|
|
16
|
+
y_max: int | None = None
|
|
17
|
+
|
|
18
|
+
@property
|
|
19
|
+
def x_slice(self) -> slice:
|
|
20
|
+
return slice(self.x_min, self.x_max)
|
|
21
|
+
|
|
22
|
+
@property
|
|
23
|
+
def y_slice(self) -> slice:
|
|
24
|
+
return slice(self.y_min, self.y_max)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
@dataclass(frozen=True)
|
|
28
|
+
class AnalysisConfig:
|
|
29
|
+
input_file: Path
|
|
30
|
+
output_dir: Path
|
|
31
|
+
frame_start: int = 0
|
|
32
|
+
frame_stop: int | None = None
|
|
33
|
+
chunk_frames: int = 512
|
|
34
|
+
chunk_overlap_frames: int = 0
|
|
35
|
+
processing_mode: str = "chunked"
|
|
36
|
+
gpu_enabled: bool = False
|
|
37
|
+
gpu_memory_fraction: float = 0.7
|
|
38
|
+
locate_workers: int = 1
|
|
39
|
+
roi: Roi = Roi()
|
|
40
|
+
fps: float = 300.0
|
|
41
|
+
pixel_size_um: float = 1.0
|
|
42
|
+
wavelength_nm: float = 532.0
|
|
43
|
+
numerical_aperture: float = 0.25
|
|
44
|
+
exposure_time_s: float = 0.0007
|
|
45
|
+
temperature_k: float = 295.15
|
|
46
|
+
viscosity_pa_s: float = 0.00095
|
|
47
|
+
channel_diameter_nm: float | None = None
|
|
48
|
+
channel_center_y_px: float | None = None
|
|
49
|
+
auto_channel_center: bool = True
|
|
50
|
+
hindrance_mode: str = "none"
|
|
51
|
+
remove_camera_offset: bool = True
|
|
52
|
+
remove_laser_fluctuation: bool = False
|
|
53
|
+
remove_static_background: bool = True
|
|
54
|
+
enhance_snr: bool = False
|
|
55
|
+
snr_kernel_sigma_px: float | None = None
|
|
56
|
+
auto_diameter: bool = False
|
|
57
|
+
auto_minmass: bool = False
|
|
58
|
+
zncc_min: float = 0.7
|
|
59
|
+
minmass_try_frames: int = 30
|
|
60
|
+
red_x_mode: str = "exp"
|
|
61
|
+
single_particle: bool = False
|
|
62
|
+
single_particle_keep: str = "longest"
|
|
63
|
+
stitch_max_gap_frames: int = 300
|
|
64
|
+
stitch_max_distance_px: float = 15.0
|
|
65
|
+
stitch_distance_growth_px: float = 0.5
|
|
66
|
+
stitch_max_mass_ratio: float = 3.0
|
|
67
|
+
stitch_min_fragment_frames: int = 3
|
|
68
|
+
background_stack_storage: str = "memory"
|
|
69
|
+
processing_bit_depth: int = 16
|
|
70
|
+
background_method: str = "legacy_trimmed_mean"
|
|
71
|
+
intensity_normalization: str = "global"
|
|
72
|
+
particle_diameter: int = 7
|
|
73
|
+
particle_minmass: float = 100000.0
|
|
74
|
+
particle_separation: int = 15
|
|
75
|
+
particle_percentile: float = 20.0
|
|
76
|
+
minimum_trajectory_frames: int = 100
|
|
77
|
+
minimum_trajectory_frames_before_drift: int = 10
|
|
78
|
+
drift_enabled: bool = True
|
|
79
|
+
msd_max_lag_frames: int = 20
|
|
80
|
+
msd_lag_auto: bool = False
|
|
81
|
+
analyze_stationary_particles: bool = True
|
|
82
|
+
stationary_search_range: float = 0.3
|
|
83
|
+
stationary_minimum_frames: int = 100
|
|
84
|
+
no_go_distance: float = 20.0
|
|
85
|
+
split_intensity_jumps: bool = False
|
|
86
|
+
max_relative_intensity_step: float = 0.906
|
|
87
|
+
maximum_trajectory_frames: int | None = None
|
|
88
|
+
keep_short_trajectory_tail: bool = False
|
|
89
|
+
apply_quality_filter: bool = True
|
|
90
|
+
minimum_msd_pairs_at_max_lag: int = 5
|
|
91
|
+
minimum_ks_significance: float = 0.01
|
|
92
|
+
ks_method: str = "exact"
|
|
93
|
+
saturated_pixel_value: float | None = None
|
|
94
|
+
maximum_diameter_nm: float | None = None
|
|
95
|
+
force_ultra_uniform: bool = False
|
|
96
|
+
ultra_uniform_z_max: float = 2.576
|
|
97
|
+
|
|
98
|
+
def validate(self) -> None:
|
|
99
|
+
if not self.input_file.is_file():
|
|
100
|
+
raise FileNotFoundError(f"Input TIFF does not exist: {self.input_file}")
|
|
101
|
+
if self.frame_start < 0:
|
|
102
|
+
raise ValueError("frame_start must be non-negative")
|
|
103
|
+
if self.frame_stop is not None and self.frame_stop <= self.frame_start:
|
|
104
|
+
raise ValueError("frame_stop must be greater than frame_start")
|
|
105
|
+
if self.chunk_frames <= 0:
|
|
106
|
+
raise ValueError("chunk_frames must be positive")
|
|
107
|
+
if self.chunk_overlap_frames < 0 or self.chunk_overlap_frames >= self.chunk_frames:
|
|
108
|
+
raise ValueError("chunk_overlap_frames must be in [0, chunk_frames)")
|
|
109
|
+
if self.processing_mode not in {"chunked", "single_stack"}:
|
|
110
|
+
raise ValueError("processing_mode must be 'chunked' or 'single_stack'")
|
|
111
|
+
if self.background_stack_storage not in {"memory", "disk"}:
|
|
112
|
+
raise ValueError(
|
|
113
|
+
"background_stack_storage must be 'memory' or 'disk'"
|
|
114
|
+
)
|
|
115
|
+
if self.processing_bit_depth not in {8, 16}:
|
|
116
|
+
raise ValueError("processing_bit_depth must be 8 or 16")
|
|
117
|
+
if self.background_method not in {"median", "legacy_trimmed_mean"}:
|
|
118
|
+
raise ValueError("background_method must be 'median' or 'legacy_trimmed_mean'")
|
|
119
|
+
if self.intensity_normalization not in {"global", "block"}:
|
|
120
|
+
raise ValueError("intensity_normalization must be 'global' or 'block'")
|
|
121
|
+
if self.hindrance_mode not in {"none", "legacy", "position"}:
|
|
122
|
+
raise ValueError(
|
|
123
|
+
"hindrance_mode must be 'none', 'legacy', or 'position'"
|
|
124
|
+
)
|
|
125
|
+
if self.hindrance_mode in {"legacy", "position"}:
|
|
126
|
+
if self.channel_diameter_nm is None or self.channel_diameter_nm <= 0:
|
|
127
|
+
raise ValueError(
|
|
128
|
+
"channel_diameter_nm is required for hindrance correction"
|
|
129
|
+
)
|
|
130
|
+
if self.hindrance_mode == "position" and (
|
|
131
|
+
self.channel_center_y_px is None and not self.auto_channel_center
|
|
132
|
+
):
|
|
133
|
+
raise ValueError(
|
|
134
|
+
"position hindrance correction requires channel_center_y_px "
|
|
135
|
+
"or auto_channel_center=True"
|
|
136
|
+
)
|
|
137
|
+
if not 0 < self.gpu_memory_fraction < 1:
|
|
138
|
+
raise ValueError("gpu_memory_fraction must be between 0 and 1")
|
|
139
|
+
if self.fps <= 0:
|
|
140
|
+
raise ValueError("fps must be positive")
|
|
141
|
+
if self.pixel_size_um <= 0:
|
|
142
|
+
raise ValueError("pixel_size_um must be positive")
|
|
143
|
+
if self.wavelength_nm <= 0:
|
|
144
|
+
raise ValueError("wavelength_nm must be positive")
|
|
145
|
+
if self.numerical_aperture <= 0:
|
|
146
|
+
raise ValueError("numerical_aperture must be positive")
|
|
147
|
+
if self.red_x_mode not in {"exp", "theory"}:
|
|
148
|
+
raise ValueError("red_x_mode must be 'exp' or 'theory'")
|
|
149
|
+
if self.single_particle_keep not in {"longest", "brightest", "all"}:
|
|
150
|
+
raise ValueError("single_particle_keep must be 'longest', 'brightest' or 'all'")
|
|
151
|
+
if (
|
|
152
|
+
self.stitch_max_gap_frames < 1
|
|
153
|
+
or self.stitch_max_distance_px <= 0
|
|
154
|
+
or self.stitch_distance_growth_px < 0
|
|
155
|
+
or self.stitch_max_mass_ratio < 1
|
|
156
|
+
or self.stitch_min_fragment_frames < 1
|
|
157
|
+
):
|
|
158
|
+
raise ValueError("Invalid stitching parameters")
|
|
159
|
+
if not 0 < self.zncc_min < 1:
|
|
160
|
+
raise ValueError("zncc_min must be between 0 and 1")
|
|
161
|
+
if self.minmass_try_frames < 1:
|
|
162
|
+
raise ValueError("minmass_try_frames must be at least 1")
|
|
163
|
+
if self.snr_kernel_sigma_px is not None and self.snr_kernel_sigma_px <= 0:
|
|
164
|
+
raise ValueError("snr_kernel_sigma_px must be positive")
|
|
165
|
+
if self.exposure_time_s <= 0:
|
|
166
|
+
raise ValueError("exposure_time_s must be positive")
|
|
167
|
+
if self.minimum_trajectory_frames < 2 or self.minimum_trajectory_frames_before_drift < 2:
|
|
168
|
+
raise ValueError("minimum trajectory lengths must be at least 2")
|
|
169
|
+
if self.msd_max_lag_frames < 2:
|
|
170
|
+
raise ValueError("msd_max_lag_frames must be at least 2")
|
|
171
|
+
if self.particle_diameter < 3 or self.particle_diameter % 2 == 0:
|
|
172
|
+
raise ValueError("particle_diameter must be an odd integer >= 3")
|
|
173
|
+
if self.stationary_search_range <= 0 or self.stationary_minimum_frames < 2:
|
|
174
|
+
raise ValueError("Stationary-particle parameters must be positive")
|
|
175
|
+
if self.no_go_distance < 0:
|
|
176
|
+
raise ValueError("no_go_distance must be non-negative")
|
|
177
|
+
if self.max_relative_intensity_step <= 0:
|
|
178
|
+
raise ValueError("max_relative_intensity_step must be positive")
|
|
179
|
+
if self.minimum_msd_pairs_at_max_lag < 2:
|
|
180
|
+
raise ValueError("minimum_msd_pairs_at_max_lag must be at least 2")
|
|
181
|
+
if self.ks_method not in {"exact", "legacy"}:
|
|
182
|
+
raise ValueError("ks_method must be 'exact' or 'legacy'")
|
|
183
|
+
if not 0 < self.minimum_ks_significance < 1:
|
|
184
|
+
raise ValueError("minimum_ks_significance must be between 0 and 1")
|
|
185
|
+
if self.ultra_uniform_z_max <= 0:
|
|
186
|
+
raise ValueError("ultra_uniform_z_max must be positive")
|
|
187
|
+
if self.maximum_diameter_nm is not None and self.maximum_diameter_nm <= 0:
|
|
188
|
+
raise ValueError("maximum_diameter_nm must be positive")
|
|
189
|
+
if (
|
|
190
|
+
self.maximum_trajectory_frames is not None
|
|
191
|
+
and self.maximum_trajectory_frames < self.minimum_trajectory_frames_before_drift
|
|
192
|
+
):
|
|
193
|
+
raise ValueError("maximum_trajectory_frames is too short")
|
|
194
|
+
|
|
195
|
+
@property
|
|
196
|
+
def effective_snr_sigma_px(self) -> float:
|
|
197
|
+
"""Gaussian sigma of the SNR filter; the PSF width when KernelSize is "auto"."""
|
|
198
|
+
if self.snr_kernel_sigma_px is not None:
|
|
199
|
+
return self.snr_kernel_sigma_px
|
|
200
|
+
return sigma_psf_px(
|
|
201
|
+
self.numerical_aperture, self.wavelength_nm, self.pixel_size_um
|
|
202
|
+
)
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
"""Drift estimation from linked trajectories."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
import trackpy as tp
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def correct_global(
|
|
11
|
+
trajectories: pd.DataFrame,
|
|
12
|
+
window: int = 20,
|
|
13
|
+
min_particles: int = 2,
|
|
14
|
+
minimum_frames: int = 1,
|
|
15
|
+
) -> tuple[pd.DataFrame, pd.DataFrame]:
|
|
16
|
+
"""Estimate frame-to-frame common motion and subtract its cumulative sum."""
|
|
17
|
+
required = {"frame", "x", "y"}
|
|
18
|
+
missing = required.difference(trajectories.columns)
|
|
19
|
+
if missing:
|
|
20
|
+
raise ValueError(f"Trajectories are missing columns: {sorted(missing)}")
|
|
21
|
+
result = trajectories.copy().sort_values(["frame", "particle"])
|
|
22
|
+
# Saturated positions are poorly localized and, as in 1.0, excluded from the estimate
|
|
23
|
+
estimation = result
|
|
24
|
+
if "saturated" in result:
|
|
25
|
+
estimation = result[~result["saturated"].astype(bool)]
|
|
26
|
+
if minimum_frames > 1:
|
|
27
|
+
lengths = estimation.groupby("particle")["frame"].transform("size")
|
|
28
|
+
estimation = estimation[lengths >= minimum_frames]
|
|
29
|
+
drift = tp.compute_drift(
|
|
30
|
+
estimation[["particle", "frame", "x", "y"]],
|
|
31
|
+
smoothing=window,
|
|
32
|
+
)
|
|
33
|
+
full_index = result["frame"].sort_values().unique()
|
|
34
|
+
# trackpy starts at the second frame; the first frame is the zero-drift reference (as in 1.0)
|
|
35
|
+
drift = drift.reindex(full_index).interpolate(method="linear").ffill().fillna(0.0)
|
|
36
|
+
drift.index.name = "frame"
|
|
37
|
+
drift = drift.rename(columns={"x": "drift_x", "y": "drift_y"}).reset_index()
|
|
38
|
+
if drift.empty:
|
|
39
|
+
result["x_corrected"] = result["x"]
|
|
40
|
+
result["y_corrected"] = result["y"]
|
|
41
|
+
return result, drift
|
|
42
|
+
correction = drift.set_index("frame")[["drift_x", "drift_y"]]
|
|
43
|
+
result = result.join(correction, on="frame")
|
|
44
|
+
result[["drift_x", "drift_y"]] = result[["drift_x", "drift_y"]].fillna(0.0)
|
|
45
|
+
result["x_corrected"] = result["x"] - result["drift_x"]
|
|
46
|
+
result["y_corrected"] = result["y"] - result["drift_y"]
|
|
47
|
+
return result.reset_index(drop=True), drift.reset_index(drop=True)
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
"""Optional CuPy acceleration and GPU-memory-aware chunk sizing."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def get_cupy():
|
|
9
|
+
"""Return CuPy or ``None`` when the optional GPU backend is unavailable."""
|
|
10
|
+
try:
|
|
11
|
+
import cupy
|
|
12
|
+
except ImportError:
|
|
13
|
+
return None
|
|
14
|
+
return cupy
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def gpu_available() -> bool:
|
|
18
|
+
"""Return whether a CUDA device is available to CuPy."""
|
|
19
|
+
cupy = get_cupy()
|
|
20
|
+
if cupy is None:
|
|
21
|
+
return False
|
|
22
|
+
try:
|
|
23
|
+
return int(cupy.cuda.runtime.getDeviceCount()) > 0
|
|
24
|
+
except (RuntimeError, OSError):
|
|
25
|
+
return False
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def device_memory_bytes() -> int:
|
|
29
|
+
"""Return free memory on the active CUDA device."""
|
|
30
|
+
cupy = get_cupy()
|
|
31
|
+
if cupy is None:
|
|
32
|
+
raise RuntimeError("CuPy is not installed")
|
|
33
|
+
try:
|
|
34
|
+
free, _ = cupy.cuda.Device().mem_info
|
|
35
|
+
except (RuntimeError, OSError) as exc:
|
|
36
|
+
raise RuntimeError("Could not query CUDA device memory") from exc
|
|
37
|
+
return int(free)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def choose_chunk_frames(
|
|
41
|
+
requested_frames: int,
|
|
42
|
+
height: int,
|
|
43
|
+
width: int,
|
|
44
|
+
memory_fraction: float,
|
|
45
|
+
bytes_per_pixel: int = 4,
|
|
46
|
+
) -> int:
|
|
47
|
+
"""Limit a chunk to a safe fraction of currently free GPU memory."""
|
|
48
|
+
if not 0 < memory_fraction < 1:
|
|
49
|
+
raise ValueError("gpu_memory_fraction must be between 0 and 1")
|
|
50
|
+
free = device_memory_bytes()
|
|
51
|
+
# Input, float32 working copy, background median, and output need headroom.
|
|
52
|
+
bytes_per_frame = height * width * bytes_per_pixel
|
|
53
|
+
safe_frames = int(free * memory_fraction / max(bytes_per_frame * 4, 1))
|
|
54
|
+
return max(1, min(requested_frames, safe_frames))
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def preprocess_gpu(
|
|
58
|
+
frames: np.ndarray,
|
|
59
|
+
remove_camera_offset: bool,
|
|
60
|
+
remove_laser_fluctuation: bool = False,
|
|
61
|
+
remove_static_background: bool = True,
|
|
62
|
+
background_method: str = "legacy_trimmed_mean",
|
|
63
|
+
enhance_snr: bool = False,
|
|
64
|
+
background: np.ndarray | None = None,
|
|
65
|
+
offset: float | None = None,
|
|
66
|
+
snr_sigma_px: float = 1.0,
|
|
67
|
+
) -> np.ndarray:
|
|
68
|
+
"""Run preprocessing on CUDA and return a NumPy array for Trackpy."""
|
|
69
|
+
cupy = get_cupy()
|
|
70
|
+
if cupy is None or not gpu_available():
|
|
71
|
+
raise RuntimeError("GPU preprocessing requested but no CUDA/CuPy device is available")
|
|
72
|
+
device_frames = cupy.asarray(frames, dtype=cupy.float32)
|
|
73
|
+
if remove_camera_offset:
|
|
74
|
+
device_frames -= cupy.min(device_frames) if offset is None else offset
|
|
75
|
+
if remove_laser_fluctuation:
|
|
76
|
+
frame_intensity = cupy.mean(device_frames, axis=(1, 2))
|
|
77
|
+
reference = cupy.median(frame_intensity)
|
|
78
|
+
device_frames /= cupy.maximum(
|
|
79
|
+
frame_intensity[:, None, None] / cupy.maximum(reference, 1e-12),
|
|
80
|
+
1e-12,
|
|
81
|
+
)
|
|
82
|
+
if remove_static_background:
|
|
83
|
+
if background is not None:
|
|
84
|
+
device_frames -= cupy.asarray(background, dtype=cupy.float32)
|
|
85
|
+
elif background_method == "median":
|
|
86
|
+
background = cupy.median(device_frames, axis=0, keepdims=True)
|
|
87
|
+
elif background_method == "legacy_trimmed_mean":
|
|
88
|
+
ordered = cupy.sort(device_frames, axis=0)
|
|
89
|
+
first = int(0.3 * len(ordered))
|
|
90
|
+
last = max(first + 1, int(0.7 * len(ordered)))
|
|
91
|
+
background = cupy.mean(ordered[first:last], axis=0, keepdims=True)
|
|
92
|
+
else:
|
|
93
|
+
raise ValueError(
|
|
94
|
+
"background_method must be 'median' or 'legacy_trimmed_mean'"
|
|
95
|
+
)
|
|
96
|
+
device_frames -= background
|
|
97
|
+
result = cupy.asnumpy(device_frames)
|
|
98
|
+
if enhance_snr:
|
|
99
|
+
from scipy.ndimage import gaussian_filter
|
|
100
|
+
|
|
101
|
+
result = gaussian_filter(result, sigma=(0, snr_sigma_px, snr_sigma_px))
|
|
102
|
+
del device_frames
|
|
103
|
+
cupy.get_default_memory_pool().free_all_blocks()
|
|
104
|
+
return result
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def to_uint16_gpu(frames: np.ndarray, scale_max: float | None = None) -> np.ndarray:
|
|
108
|
+
"""Convert processed frames to uint16 on CUDA."""
|
|
109
|
+
cupy = get_cupy()
|
|
110
|
+
if cupy is None or not gpu_available():
|
|
111
|
+
raise RuntimeError("GPU conversion requested but no CUDA/CuPy device is available")
|
|
112
|
+
device_frames = cupy.maximum(cupy.asarray(frames, dtype=cupy.float32), 0)
|
|
113
|
+
maximum = (
|
|
114
|
+
float(cupy.asnumpy(cupy.max(device_frames)))
|
|
115
|
+
if scale_max is None
|
|
116
|
+
else scale_max
|
|
117
|
+
)
|
|
118
|
+
if maximum == 0:
|
|
119
|
+
result = np.zeros(frames.shape, dtype=np.uint16)
|
|
120
|
+
else:
|
|
121
|
+
result = cupy.asnumpy(cupy.rint(device_frames * (65535.0 / maximum))).astype(
|
|
122
|
+
np.uint16
|
|
123
|
+
)
|
|
124
|
+
del device_frames
|
|
125
|
+
cupy.get_default_memory_pool().free_all_blocks()
|
|
126
|
+
return result
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
"""Hydrodynamic diameter and position-aware channel correction."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
from scipy.constants import Boltzmann
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def local_channel_diameter_nm(
|
|
10
|
+
y_px: np.ndarray,
|
|
11
|
+
center_y_px: float,
|
|
12
|
+
channel_diameter_nm: float,
|
|
13
|
+
pixel_size_um: float,
|
|
14
|
+
) -> float:
|
|
15
|
+
"""Estimate the mean available diameter in a circular channel."""
|
|
16
|
+
if channel_diameter_nm <= 0 or pixel_size_um <= 0:
|
|
17
|
+
raise ValueError("Channel diameter and pixel size must be positive")
|
|
18
|
+
radius_px = channel_diameter_nm / 1000.0 / pixel_size_um / 2.0
|
|
19
|
+
available = np.maximum(radius_px - np.abs(np.asarray(y_px) - center_y_px), 0.0)
|
|
20
|
+
return float(np.mean(2.0 * available * pixel_size_um * 1000.0))
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def hindrance_factor(channel_diameter_nm: float, particle_diameter_nm: float) -> float:
|
|
24
|
+
"""Return the Dechadilok-Deen diffusive hindrance factor Kd."""
|
|
25
|
+
ratio = particle_diameter_nm / channel_diameter_nm
|
|
26
|
+
if not 0 < ratio < 1:
|
|
27
|
+
raise ValueError("Particle must be smaller than the local channel")
|
|
28
|
+
h = (
|
|
29
|
+
1 + 9 / 8 * ratio * np.log(ratio) - 1.56034 * ratio
|
|
30
|
+
+ 0.528155 * ratio**2 + 1.915210 * ratio**3
|
|
31
|
+
- 2.819030 * ratio**4 + 0.270788 * ratio**5
|
|
32
|
+
+ 1.101150 * ratio**6 - 0.435933 * ratio**7
|
|
33
|
+
)
|
|
34
|
+
return float(h / (1 - ratio) ** 2)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def corrected_diameter_nm(
|
|
38
|
+
diffusion_um2_s: float,
|
|
39
|
+
temperature_k: float,
|
|
40
|
+
viscosity_pa_s: float,
|
|
41
|
+
channel_diameter_nm: float | None = None,
|
|
42
|
+
) -> float:
|
|
43
|
+
"""Convert diffusion to diameter and optionally invert channel hindrance."""
|
|
44
|
+
if diffusion_um2_s <= 0 or temperature_k <= 0 or viscosity_pa_s <= 0:
|
|
45
|
+
raise ValueError("Diffusion, temperature, and viscosity must be positive")
|
|
46
|
+
diameter = 2 * Boltzmann * temperature_k / (6 * np.pi * viscosity_pa_s) * 1e9 / (diffusion_um2_s * 1e-12)
|
|
47
|
+
if channel_diameter_nm is None or diameter >= channel_diameter_nm:
|
|
48
|
+
return float(diameter)
|
|
49
|
+
# Same damped fixed-point iteration as 1.0 (geometric mean of new and old estimate)
|
|
50
|
+
corrected, previous = diameter, 0.0
|
|
51
|
+
for _ in range(100):
|
|
52
|
+
if abs(corrected - previous) <= 0.1:
|
|
53
|
+
return float(corrected)
|
|
54
|
+
previous = corrected
|
|
55
|
+
corrected = np.sqrt(diameter * hindrance_factor(channel_diameter_nm, previous) * previous)
|
|
56
|
+
return float(previous)
|