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,157 @@
1
+ """1.0-compatible configuration for the 2.0 chunked/GPU pipeline."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ from pathlib import Path
7
+
8
+ from .config import AnalysisConfig, Roi
9
+
10
+
11
+ def _number(value, default):
12
+ try:
13
+ return float(value)
14
+ except (TypeError, ValueError):
15
+ return default
16
+
17
+
18
+ def _flag(value) -> bool:
19
+ return str(value).strip().lower() in {"1", "1.0", "true", "auto", "manual"}
20
+
21
+
22
+ def _kernel_sigma(value):
23
+ """1.0 KernelSize: "auto" (PSF width) or the Gaussian sigma in pixels."""
24
+ if str(value).strip().lower() == "auto":
25
+ return None
26
+ return _number(value, None)
27
+
28
+
29
+ def _saturation(value):
30
+ """Return the saturation threshold, or None for 'No Saturation'."""
31
+ try:
32
+ return float(value)
33
+ except (TypeError, ValueError):
34
+ return None
35
+
36
+
37
+ def load_parameter_file(parameter_file: str | Path) -> tuple[AnalysisConfig, dict]:
38
+ """Translate a 1.0 ``parameter.json`` into an equivalent 2.0 config."""
39
+ parameter_path = Path(parameter_file)
40
+ settings = json.loads(parameter_path.read_text(encoding="utf-8"))
41
+ return load_parameter_settings(settings, parameter_path)
42
+
43
+
44
+ def load_parameter_settings(
45
+ settings: dict, parameter_path: str | Path = "."
46
+ ) -> tuple[AnalysisConfig, dict]:
47
+ """Translate in-memory 1.0 settings into an equivalent 2.0 config."""
48
+ parameter_path = Path(parameter_path)
49
+ exp = settings["Exp"]
50
+ fiber = settings["Fiber"]
51
+ files = settings["File"]
52
+ roi_settings = settings["ROI"]
53
+ preprocessing = settings["PreProcessing"]
54
+ find = settings["Find"]
55
+ link = settings["Link"]
56
+ stationary = settings["StationaryObjects"]
57
+ split = settings["Split"]
58
+ drift = settings["Drift"]
59
+ msd = settings["MSD"]
60
+ help_settings = settings.get("Help", {})
61
+ single = settings.get("SingleParticle", {})
62
+ post = settings["PostProcessing"]
63
+
64
+ frame_stop = None
65
+ if roi_settings.get("frame_max", 0) and roi_settings["frame_max"] < 10**6:
66
+ frame_stop = int(roi_settings["frame_max"])
67
+ roi = Roi(
68
+ x_min=int(roi_settings.get("x_min", 0)),
69
+ x_max=None if roi_settings.get("x_max", 10**9) >= 10**9 else int(roi_settings["x_max"]),
70
+ y_min=int(roi_settings.get("y_min", 0)),
71
+ y_max=None if roi_settings.get("y_max", 10**9) >= 10**9 else int(roi_settings["y_max"]),
72
+ )
73
+ hindrance_aware = bool(msd.get("HindrancePositionAware", 0))
74
+ hindrance_mode = "position" if hindrance_aware else (
75
+ "legacy" if bool(msd.get("EstimateHindranceFactor", 0)) else "none"
76
+ )
77
+ max_diameter = _number(post.get("MaxDiameter", 0), 0.0) or None
78
+ max_trajectory = _number(split.get("Max_traj_length", 0), 0.0) or None
79
+ if max_trajectory and max_trajectory >= 10**8:
80
+ max_trajectory = None
81
+
82
+ input_file = Path(files["data_file_name"])
83
+ if not input_file.is_file():
84
+ candidate = parameter_path.parent / input_file.name
85
+ if candidate.is_file():
86
+ input_file = candidate
87
+ output_dir = Path(settings["Plot"].get("SaveFolder", parameter_path.parent))
88
+ if not output_dir.is_absolute() or not output_dir.exists():
89
+ output_dir = parameter_path.parent / "analysis" / "pipeline_2_0"
90
+
91
+ config = AnalysisConfig(
92
+ input_file=input_file,
93
+ output_dir=output_dir,
94
+ frame_start=int(roi_settings.get("frame_min", 0)),
95
+ frame_stop=frame_stop,
96
+ roi=roi,
97
+ fps=_number(exp["fps"], 100.0),
98
+ pixel_size_um=_number(exp["Microns_per_pixel"], 1.0),
99
+ wavelength_nm=_number(exp.get("lambda"), 532.0),
100
+ numerical_aperture=_number(exp.get("NA"), 0.25),
101
+ exposure_time_s=_number(exp.get("ExposureTime"), 0.0007),
102
+ temperature_k=_number(exp.get("Temperature"), 293.15),
103
+ viscosity_pa_s=_number(exp.get("Viscosity"), 0.001) * 1e12,
104
+ channel_diameter_nm=_number(fiber.get("TubeDiameter_nm"), 0.0) or None,
105
+ channel_center_y_px=(
106
+ _number(fiber.get("Center_y_px"), 0.0)
107
+ if str(fiber.get("Center_y_px", "")).lower() != "unknown"
108
+ else None
109
+ ),
110
+ auto_channel_center=str(fiber.get("Center_y_px", "")).lower() == "unknown",
111
+ hindrance_mode=hindrance_mode,
112
+ remove_camera_offset=bool(preprocessing.get("Remove_CameraOffset", 1)),
113
+ remove_laser_fluctuation=bool(
114
+ preprocessing.get("Remove_Laserfluctuation", 0)
115
+ ),
116
+ remove_static_background=bool(preprocessing.get("Remove_StaticBackground", 1)),
117
+ enhance_snr=bool(preprocessing.get("EnhanceSNR", 0)),
118
+ snr_kernel_sigma_px=_kernel_sigma(preprocessing.get("KernelSize", "auto")),
119
+ zncc_min=_number(preprocessing.get("ZNCC_min"), 0.7),
120
+ auto_minmass=_flag(help_settings.get("Bead brightness", 0)),
121
+ auto_diameter=_flag(help_settings.get("Bead size", 0)),
122
+ minmass_try_frames=int(_number(help_settings.get("TryFrames"), 30)),
123
+ red_x_mode=str(msd.get("Estimate_X", "Exp")).lower(),
124
+ single_particle=bool(single.get("Enabled", 0)),
125
+ single_particle_keep=str(single.get("Keep", "longest")).lower(),
126
+ stitch_max_gap_frames=int(single.get("Max gap frames", 300)),
127
+ stitch_max_distance_px=float(single.get("Max distance px", 15.0)),
128
+ stitch_distance_growth_px=float(single.get("Distance growth px per sqrt frame", 0.5)),
129
+ stitch_max_mass_ratio=float(single.get("Max mass ratio", 3.0)),
130
+ stitch_min_fragment_frames=int(single.get("Min fragment frames", 3)),
131
+ background_method="legacy_trimmed_mean",
132
+ intensity_normalization="global",
133
+ particle_diameter=int(find.get("tp_diameter", 7)),
134
+ particle_minmass=_number(find.get("tp_minmass"), 358.0),
135
+ particle_separation=int(find.get("tp_separation", 20)),
136
+ particle_percentile=_number(find.get("tp_percentile"), 20.0),
137
+ minimum_trajectory_frames=int(link.get("Min_tracking_frames", 5)),
138
+ minimum_trajectory_frames_before_drift=int(link.get("Min tracking frames before drift", 10)),
139
+ drift_enabled=bool(drift.get("Apply", 1)),
140
+ msd_max_lag_frames=max(2, int(msd.get("lagtimes_max", 3))),
141
+ msd_lag_auto=bool(msd.get("Amount lagtimes auto", 0)),
142
+ analyze_stationary_particles=bool(stationary.get("Analyze fixed spots", 1)),
143
+ stationary_search_range=_number(link.get("Max displacement fix"), 0.3),
144
+ stationary_minimum_frames=int(link.get("Dwell time stationary objects", 100)),
145
+ no_go_distance=_number(stationary.get("Min distance to stationary object"), 20.0),
146
+ split_intensity_jumps=bool(split.get("IntensityJump", 0)),
147
+ max_relative_intensity_step=_number(split.get("Max rel median intensity step"), 0.906),
148
+ maximum_trajectory_frames=None if max_trajectory is None else int(max_trajectory),
149
+ keep_short_trajectory_tail=bool(split.get("Max_traj_length_keep_tail", 0)),
150
+ ks_method=str(msd.get("KSMethod", "legacy")).lower(),
151
+ minimum_ks_significance=_number(msd.get("KSMinSignificance"), 0.01),
152
+ saturated_pixel_value=_saturation(find.get("SaturatedPixelValue")),
153
+ apply_quality_filter=bool(msd.get("CheckBrownianMotion", 1)),
154
+ maximum_diameter_nm=max_diameter,
155
+ force_ultra_uniform=bool(post.get("ForceUltraUniform", 0)),
156
+ )
157
+ return config, settings
@@ -0,0 +1,121 @@
1
+ """Write CSV files with the file names, column order and columns used by 1.0."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ OBJECT_COLUMNS = [
11
+ "y", "x", "mass", "size", "ecc", "signal", "raw_mass", "ep", "frame",
12
+ "abstime", "saturated",
13
+ ]
14
+ TRAJECTORY_COLUMNS = [
15
+ "frame", "y", "x", "mass", "size", "ecc", "signal", "raw_mass", "ep",
16
+ "abstime", "saturated", "particle",
17
+ ]
18
+ SIZE_COLUMNS = [
19
+ "particle", "diameter", "diffusion", "diffusion std", "ep", "red_x",
20
+ "signal", "mass", "rawmass_mean", "rawmass_median", "rawmass_max",
21
+ "max step", "first frame", "traj length", "valid frames", "size", "ecc",
22
+ "stat_sign", "true_particle", "x in first frame", "y in first frame",
23
+ ]
24
+
25
+
26
+ def _ordered(frame: pd.DataFrame, columns: list[str]) -> pd.DataFrame:
27
+ return frame.reindex(columns=columns)
28
+
29
+
30
+ def write_legacy_table(frame: pd.DataFrame, path: Path, columns: list[str]) -> None:
31
+ _ordered(frame, columns).to_csv(path, index=False)
32
+
33
+
34
+ def legacy_trajectories(corrected: pd.DataFrame) -> pd.DataFrame:
35
+ """Return a trajectory table whose x/y are the drift-corrected positions."""
36
+ result = corrected.copy()
37
+ if "x_corrected" in result:
38
+ result["x"] = result["x_corrected"]
39
+ result["y"] = result["y_corrected"]
40
+ return _ordered(result, TRAJECTORY_COLUMNS)
41
+
42
+
43
+ def legacy_sizes_table(
44
+ corrected: pd.DataFrame,
45
+ moving: pd.DataFrame,
46
+ estimates: pd.DataFrame,
47
+ quality: pd.DataFrame,
48
+ fps: float,
49
+ ) -> pd.DataFrame:
50
+ """Build the per-particle table written as ``sizes_df_lin_*.csv`` by 1.0."""
51
+ valid = estimates[estimates["diameter_nm"].notna()].copy()
52
+ if valid.empty:
53
+ return pd.DataFrame(columns=SIZE_COLUMNS)
54
+ stat = {}
55
+ if len(quality) and "ks_significance" in quality:
56
+ stat = dict(zip(quality["particle"], quality["ks_significance"]))
57
+ start = (
58
+ moving.sort_values("frame").groupby("particle").first()
59
+ if len(moving)
60
+ else pd.DataFrame()
61
+ )
62
+ tracks = {particle: group for particle, group in corrected.groupby("particle")}
63
+ lagtime = 1.0 / fps
64
+ rows = []
65
+ for _, row in valid.iterrows():
66
+ particle = row["particle"]
67
+ track = tracks.get(particle)
68
+ if track is None:
69
+ continue
70
+ track = track.sort_values("frame")
71
+ red_x = row["intercept_um2"] / (lagtime * row["slope_um2_s"])
72
+ red_x = max(red_x, -0.5)
73
+ valid_frames = row["valid_frames"]
74
+ error = np.sqrt(
75
+ 2.0 / (valid_frames - 1) * (1.0 + 2.0 * np.sqrt(1.0 + 2.0 * red_x))
76
+ )
77
+ first = start.loc[particle] if particle in start.index else track.iloc[0]
78
+ rows.append(
79
+ {
80
+ "particle": int(particle),
81
+ "diameter": row["diameter_nm"],
82
+ "diffusion": row["diffusion_um2_s"],
83
+ "diffusion std": row["diffusion_um2_s"] * error,
84
+ "ep": track["ep"].mean(),
85
+ "red_x": red_x,
86
+ "signal": track["signal"].mean(),
87
+ "mass": track["mass"].mean(),
88
+ "rawmass_mean": track["raw_mass"].mean(),
89
+ "rawmass_median": track["raw_mass"].median(),
90
+ "rawmass_max": track["raw_mass"].max(),
91
+ "max step": 0.0,
92
+ "first frame": float(track["frame"].iloc[0]),
93
+ "traj length": float(track["frame"].iloc[-1] - track["frame"].iloc[0] + 1),
94
+ "valid frames": float(valid_frames),
95
+ "size": track["size"].mean(),
96
+ "ecc": track["ecc"].mean(),
97
+ "stat_sign": stat.get(particle, np.nan),
98
+ "true_particle": float(particle),
99
+ "x in first frame": first["x"],
100
+ "y in first frame": first["y"],
101
+ }
102
+ )
103
+ return pd.DataFrame(rows, columns=SIZE_COLUMNS)
104
+
105
+
106
+ def export_legacy_aliases(output_dir: Path) -> None:
107
+ """Write 1.0-compatible detection and trajectory CSVs without removing 2.0 files."""
108
+ detections = output_dir / "detections.csv"
109
+ if detections.is_file():
110
+ table = pd.read_csv(detections)
111
+ write_legacy_table(table, output_dir / "obj_all.csv", OBJECT_COLUMNS)
112
+ write_legacy_table(
113
+ table, output_dir / "obj_moving_no_go_area.csv", OBJECT_COLUMNS
114
+ )
115
+ for source, target in (
116
+ ("stationary_trajectories.csv", "traj_fixed.csv"),
117
+ ("trajectories.csv", "traj_moving.csv"),
118
+ ):
119
+ path = output_dir / source
120
+ if path.is_file():
121
+ write_legacy_table(pd.read_csv(path), output_dir / target, TRAJECTORY_COLUMNS)
@@ -0,0 +1,73 @@
1
+ """Particle localization using a direct, optional Trackpy dependency."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import pandas as pd
6
+ import numpy as np
7
+ import os
8
+ import sys
9
+ from concurrent.futures import ProcessPoolExecutor
10
+
11
+ def _locate_frame(args):
12
+ import trackpy as tp
13
+
14
+ frame, diameter, minmass, separation, percentile, preprocess = args
15
+ return tp.locate(
16
+ frame,
17
+ diameter,
18
+ minmass=minmass,
19
+ separation=separation,
20
+ percentile=percentile,
21
+ preprocess=preprocess,
22
+ engine="python",
23
+ )
24
+
25
+
26
+ def locate(
27
+ frames: np.ndarray,
28
+ diameter: int,
29
+ minmass: float,
30
+ separation: int,
31
+ percentile: float,
32
+ preprocess: bool = True,
33
+ workers: int = 1,
34
+ executor: ProcessPoolExecutor | None = None,
35
+ ) -> pd.DataFrame:
36
+ """Locate particles in one block and preserve local frame numbers.
37
+
38
+ ``workers`` > 1 spreads frames over processes; 0 uses all cores.
39
+ """
40
+ try:
41
+ import trackpy as tp # noqa: F401
42
+ except ImportError as exc:
43
+ raise RuntimeError(
44
+ "Particle localization requires 'trackpy', but it is not "
45
+ f"installed in the active interpreter ({sys.executable}). "
46
+ "Install it with: python -m pip install trackpy"
47
+ ) from exc
48
+
49
+ frame_list = np.asarray(frames)
50
+ jobs = [
51
+ (frame, diameter, minmass, separation, percentile, preprocess)
52
+ for frame in frame_list
53
+ ]
54
+ workers = (os.cpu_count() or 1) if workers == 0 else workers
55
+ if workers > 1 and len(jobs) > 1:
56
+ # A few large batches per worker keep process start-up and pickling overhead low
57
+ batch = max(1, -(-len(jobs) // (workers * 4)))
58
+ if executor is not None:
59
+ results = list(executor.map(_locate_frame, jobs, chunksize=batch))
60
+ else:
61
+ with ProcessPoolExecutor(max_workers=workers) as pool:
62
+ results = list(pool.map(_locate_frame, jobs, chunksize=batch))
63
+ else:
64
+ results = [_locate_frame(job) for job in jobs]
65
+ detections = []
66
+ for frame_number, found in enumerate(results):
67
+ if not found.empty:
68
+ found = found.copy()
69
+ found["frame"] = frame_number
70
+ detections.append(found)
71
+ if not detections:
72
+ return pd.DataFrame(columns=["x", "y", "frame"])
73
+ return pd.concat(detections, ignore_index=True)
@@ -0,0 +1,215 @@
1
+ """Mean-squared-displacement and diffusion estimation."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import warnings
6
+
7
+ import numpy as np
8
+ import pandas as pd
9
+
10
+ from .progress import progress
11
+
12
+
13
+ def calculate_msd(
14
+ trajectory: pd.DataFrame,
15
+ dimension: str = "2d",
16
+ max_lag: int | None = None,
17
+ pixel_size_um: float = 1.0,
18
+ fps: float = 300.0,
19
+ ) -> pd.DataFrame:
20
+ """Calculate MSD in µm² for one trajectory."""
21
+ if dimension not in {"x", "y", "2d"}:
22
+ raise ValueError("dimension must be 'x', 'y', or '2d'")
23
+ x_col = "x_corrected" if "x_corrected" in trajectory else "x"
24
+ y_col = "y_corrected" if "y_corrected" in trajectory else "y"
25
+ values = trajectory.sort_values("frame").drop_duplicates("frame")
26
+ frames = values["frame"].to_numpy(int)
27
+ x = values[x_col].to_numpy(float) * pixel_size_um
28
+ y = values[y_col].to_numpy(float) * pixel_size_um
29
+ n = len(values)
30
+ # Short lags are less affected by channel confinement and residual drift.
31
+ limit = min(
32
+ int(frames[-1] - frames[0]) if n else 0,
33
+ max_lag or min(max(1, n // 4), 20),
34
+ )
35
+ rows = []
36
+ for lag in range(1, limit + 1):
37
+ right = np.searchsorted(frames, frames + lag)
38
+ valid = right < n
39
+ valid &= frames[np.minimum(right, n - 1)] == frames + lag
40
+ left = np.nonzero(valid)[0]
41
+ if not len(left):
42
+ continue
43
+ dx = x[right[left]] - x[left]
44
+ dy = y[right[left]] - y[left]
45
+ squared = dx * dx if dimension == "x" else dy * dy if dimension == "y" else dx * dx + dy * dy
46
+ rows.append({"lag": lag, "time_s": lag / fps, "msd_um2": float(np.mean(squared)), "pairs": len(squared)})
47
+ return pd.DataFrame(rows)
48
+
49
+
50
+ def estimate_diffusion(msd: pd.DataFrame, dimension: str = "2d") -> dict[str, float]:
51
+ """Fit MSD = intercept + 2*d*D*time and return D in µm²/s."""
52
+ if len(msd) < 2:
53
+ raise ValueError("At least two MSD points are required")
54
+ time = msd["time_s"].to_numpy(dtype=float)
55
+ values = msd["msd_um2"].to_numpy(dtype=float)
56
+ centered_time = time - float(np.mean(time))
57
+ centered_values = values - float(np.mean(values))
58
+ denominator = float(np.dot(centered_time, centered_time))
59
+ if denominator == 0:
60
+ raise ValueError("MSD fit requires at least two distinct lag times")
61
+ slope = float(np.dot(centered_time, centered_values) / denominator)
62
+ intercept = float(np.mean(values) - slope * np.mean(time))
63
+ factor = 4.0 if dimension == "2d" else 2.0
64
+ return {
65
+ "diffusion_um2_s": float(slope / factor),
66
+ "slope_um2_s": float(slope),
67
+ "intercept_um2": float(intercept),
68
+ "fit_points": float(len(msd)),
69
+ }
70
+
71
+
72
+ def _ols(time: np.ndarray, values: np.ndarray) -> tuple[float, float]:
73
+ slope, intercept = np.polyfit(time, values, 1)
74
+ return float(slope), float(intercept)
75
+
76
+
77
+ def _block_msd(positions: list[np.ndarray], lag: int) -> tuple[float, int]:
78
+ """Legacy MSD at one lag: mean of non-overlapping block means of the squared displacements."""
79
+ total = len(positions[0])
80
+ squared = np.zeros(total)
81
+ for pos in positions:
82
+ displacement = np.full(total, np.nan)
83
+ displacement[lag:] = pos[lag:] - pos[:-lag]
84
+ squared = squared + displacement**2
85
+ pairs = int(np.count_nonzero(~np.isnan(squared)))
86
+ blocks = (total - lag) // lag + 1
87
+ if blocks < 1:
88
+ return float("nan"), pairs
89
+ with warnings.catch_warnings():
90
+ warnings.simplefilter("ignore", category=RuntimeWarning)
91
+ block_means = np.nanmean(squared[: blocks * lag].reshape(blocks, lag), axis=1)
92
+ return float(np.nanmean(block_means)), pairs
93
+
94
+
95
+ def estimate_diffusion_auto_lag(
96
+ trajectory: pd.DataFrame,
97
+ dimension: str,
98
+ pixel_size_um: float,
99
+ fps: float,
100
+ max_iterations: int = 10,
101
+ fixed_red_x: float | None = None,
102
+ ) -> dict[str, float] | None:
103
+ """Reproduce the 1.0 MSD fit with automatic lag selection (Michalet 2012, appendix B).
104
+
105
+ Follows CalcDiameter.OptimizeMSD of 1.0 step by step, including its details:
106
+ block-averaged MSD, the slope being fitted with the second entry of the lag pair
107
+ returned by UpdateP_Min (the offset with the first), and convergence on that entry.
108
+ Returns None if the track is too short, has too many holes or does not converge.
109
+ """
110
+ values = trajectory.sort_values("frame").drop_duplicates("frame")
111
+ n_rows = len(values)
112
+ frames = values["frame"].to_numpy(int)
113
+ x_col = "x_corrected" if "x_corrected" in values else "x"
114
+ y_col = "y_corrected" if "y_corrected" in values else "y"
115
+ total = int(frames[-1] - frames[0]) + 1 if n_rows else 0
116
+ positions = []
117
+ for use, column in ((dimension in ("x", "2d"), x_col), (dimension in ("y", "2d"), y_col)):
118
+ if use:
119
+ pos = np.full(total, np.nan)
120
+ pos[frames - frames[0]] = values[column].to_numpy(float) * pixel_size_um
121
+ positions.append(pos)
122
+ if not positions:
123
+ raise ValueError("dimension must be 'x', 'y', or '2d'")
124
+ start = min(max(int(np.ceil(n_rows / 10)), 2), 100)
125
+ lags = [start, start]
126
+ factor = 4.0 if dimension == "2d" else 2.0
127
+ t_frame = 1.0 / fps
128
+ for counter in range(max_iterations + 1):
129
+ lag_max = max(lags)
130
+ if total < lag_max:
131
+ return None
132
+ msd, pairs = zip(*(_block_msd(positions, lag) for lag in range(1, lag_max + 1)))
133
+ if pairs[lag_max - 1] < lag_max + 1 or np.isnan(msd).any():
134
+ return None
135
+ pairs_lag1 = pairs[0]
136
+ time = np.arange(1, lag_max + 1) / fps
137
+ msd = np.asarray(msd)
138
+ slope, _ = _ols(time[: lags[1]], msd[: lags[1]])
139
+ _, intercept = _ols(time[: lags[0]], msd[: lags[0]])
140
+ l_a = 3 + max(4.5 * pairs_lag1**0.4 - 8.5, 0.0) ** 1.2
141
+ l_b = 0.8 + 0.564 * pairs_lag1
142
+ if slope < 0:
143
+ new = [int(l_a), int(l_b)]
144
+ else:
145
+ if fixed_red_x is not None:
146
+ red_x = max(fixed_red_x, 0.0)
147
+ else:
148
+ red_x = max(intercept / (t_frame * slope), 0.0) if slope else 0.0
149
+ f_a = 2 + 1.6 * red_x**0.51
150
+ p_a = max(np.floor(f_a * l_a / (f_a**3 + l_a**3) ** (1 / 3)), 2)
151
+ f_b = 2 + 1.35 * red_x**0.6
152
+ p_b = max(np.floor(min(l_b, f_b * l_b / (f_b**3 + l_b**3) ** (1 / 3))), 2)
153
+ new = [int(p_b), int(p_a)]
154
+ if counter == max_iterations:
155
+ return None
156
+ if new[1] == lags[1]:
157
+ return {
158
+ "diffusion_um2_s": slope / factor,
159
+ "slope_um2_s": slope,
160
+ "intercept_um2": intercept,
161
+ "fit_points": float(lags[1]),
162
+ "msd_pairs": int(sum(pairs)),
163
+ "valid_frames": int(pairs_lag1),
164
+ }
165
+ lags = new
166
+ return None
167
+
168
+
169
+ def estimate_all(
170
+ trajectories: pd.DataFrame,
171
+ auto_lag: bool = False,
172
+ fixed_red_x: float | None = None,
173
+ **kwargs,
174
+ ) -> pd.DataFrame:
175
+ rows = []
176
+ dimension = kwargs.get("dimension", "2d")
177
+ groups = trajectories.groupby("particle")
178
+ for particle, trajectory in progress(
179
+ groups,
180
+ desc="Estimating MSD and particle size",
181
+ total=trajectories["particle"].nunique(),
182
+ unit="particle",
183
+ ):
184
+ if auto_lag:
185
+ row = estimate_diffusion_auto_lag(
186
+ trajectory,
187
+ dimension,
188
+ kwargs.get("pixel_size_um", 1.0),
189
+ kwargs.get("fps", 300.0),
190
+ fixed_red_x=fixed_red_x,
191
+ )
192
+ if row is None:
193
+ continue
194
+ else:
195
+ msd = calculate_msd(trajectory, **kwargs)
196
+ if len(msd) < 2:
197
+ continue
198
+ row = estimate_diffusion(msd, dimension)
199
+ row["msd_pairs"] = int(msd["pairs"].sum())
200
+ row["valid_frames"] = int(msd["pairs"].iloc[0])
201
+ row.update(
202
+ {
203
+ "particle": particle,
204
+ "trajectory_frames": len(trajectory),
205
+ "mean_brightness": float(
206
+ trajectory["mass"].mean()
207
+ if "mass" in trajectory
208
+ else trajectory["signal"].mean()
209
+ if "signal" in trajectory
210
+ else float("nan")
211
+ ),
212
+ }
213
+ )
214
+ rows.append(row)
215
+ return pd.DataFrame(rows)