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