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,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)