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,187 @@
|
|
|
1
|
+
"""Chunked TIFF storage for the independent pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from collections.abc import Iterator
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
import numpy as np
|
|
11
|
+
|
|
12
|
+
from .progress import progress
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _tifffile():
|
|
16
|
+
try:
|
|
17
|
+
import tifffile
|
|
18
|
+
except ImportError as exc:
|
|
19
|
+
raise RuntimeError("Install 'tifffile' to use TIFF storage.") from exc
|
|
20
|
+
return tifffile
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def tiff_info(path: Path) -> dict[str, Any]:
|
|
24
|
+
tifffile = _tifffile()
|
|
25
|
+
with tifffile.TiffFile(path) as tif:
|
|
26
|
+
series = tif.series[0]
|
|
27
|
+
return {
|
|
28
|
+
"path": str(path.resolve()),
|
|
29
|
+
"shape": tuple(series.shape),
|
|
30
|
+
"axes": series.axes,
|
|
31
|
+
"dtype": str(series.dtype),
|
|
32
|
+
"pages": len(tif.pages),
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
def to_processing_bit_depth(
|
|
37
|
+
frames: np.ndarray, processing_bit_depth: int
|
|
38
|
+
) -> np.ndarray:
|
|
39
|
+
"""Convert unsigned TIFF frames to the selected processing bit depth."""
|
|
40
|
+
if processing_bit_depth == 16:
|
|
41
|
+
return frames
|
|
42
|
+
if processing_bit_depth != 8:
|
|
43
|
+
raise ValueError("processing_bit_depth must be 8 or 16")
|
|
44
|
+
|
|
45
|
+
data = np.asarray(frames)
|
|
46
|
+
if data.dtype.kind != "u":
|
|
47
|
+
raise ValueError(
|
|
48
|
+
"8-bit processing requires an unsigned 8-bit or 16-bit TIFF stack"
|
|
49
|
+
)
|
|
50
|
+
if data.dtype.itemsize == 1:
|
|
51
|
+
return data
|
|
52
|
+
if data.dtype.itemsize == 2:
|
|
53
|
+
return np.right_shift(data, 8).astype(np.uint8)
|
|
54
|
+
raise ValueError(
|
|
55
|
+
"8-bit processing requires an unsigned 8-bit or 16-bit TIFF stack"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_frames(path: Path, first: int, stop: int) -> np.ndarray:
|
|
60
|
+
tifffile = _tifffile()
|
|
61
|
+
try:
|
|
62
|
+
mapped = tifffile.memmap(path, mode="r")
|
|
63
|
+
result = np.array(mapped[first:stop], copy=True)
|
|
64
|
+
del mapped
|
|
65
|
+
return result
|
|
66
|
+
except (OSError, ValueError, RuntimeError):
|
|
67
|
+
return np.asarray(tifffile.imread(path, key=slice(first, stop)))
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def iter_tiff_frames(
|
|
71
|
+
path: Path, frame_start: int, frame_stop: int | None
|
|
72
|
+
) -> Iterator[tuple[int, np.ndarray]]:
|
|
73
|
+
"""Yield source TIFF frames sequentially without creating a chunk store."""
|
|
74
|
+
tifffile = _tifffile()
|
|
75
|
+
with tifffile.TiffFile(path) as tif:
|
|
76
|
+
series = tif.series[0]
|
|
77
|
+
total = int(series.shape[0]) if len(series.shape) > 2 else len(tif.pages)
|
|
78
|
+
stop = total if frame_stop is None else min(frame_stop, total)
|
|
79
|
+
if frame_start < 0 or frame_start >= stop:
|
|
80
|
+
raise ValueError("Configured frame range is outside the source TIFF")
|
|
81
|
+
if len(tif.pages) >= total:
|
|
82
|
+
for frame_number in range(frame_start, stop):
|
|
83
|
+
yield frame_number, tif.pages[frame_number].asarray()
|
|
84
|
+
return
|
|
85
|
+
# ImageJ stacks store all frames behind a single page header; map them directly
|
|
86
|
+
mapped = tifffile.memmap(path, mode="r")
|
|
87
|
+
try:
|
|
88
|
+
for frame_number in range(frame_start, stop):
|
|
89
|
+
yield frame_number, np.array(mapped[frame_number])
|
|
90
|
+
finally:
|
|
91
|
+
del mapped
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def create_store(
|
|
95
|
+
source: Path,
|
|
96
|
+
target: Path,
|
|
97
|
+
frame_start: int,
|
|
98
|
+
frame_stop: int | None,
|
|
99
|
+
chunk_frames: int,
|
|
100
|
+
chunk_overlap_frames: int,
|
|
101
|
+
y_slice: slice,
|
|
102
|
+
x_slice: slice,
|
|
103
|
+
processing_bit_depth: int = 16,
|
|
104
|
+
) -> dict[str, Any]:
|
|
105
|
+
"""Create a reproducible chunk store without changing the source."""
|
|
106
|
+
info = tiff_info(source)
|
|
107
|
+
total = int(info["shape"][0])
|
|
108
|
+
stop = total if frame_stop is None else min(frame_stop, total)
|
|
109
|
+
if frame_start >= stop:
|
|
110
|
+
raise ValueError("Configured frame range is outside the source TIFF")
|
|
111
|
+
|
|
112
|
+
chunks_dir = target / "chunks"
|
|
113
|
+
chunks_dir.mkdir(parents=True, exist_ok=True)
|
|
114
|
+
tifffile = _tifffile()
|
|
115
|
+
manifest: dict[str, Any] = {
|
|
116
|
+
"format": "nanoobjectdetection-2.0-chunks-v1",
|
|
117
|
+
"source": str(source.resolve()),
|
|
118
|
+
"axes": info["axes"],
|
|
119
|
+
"source_dtype": info["dtype"],
|
|
120
|
+
"dtype": (
|
|
121
|
+
"uint8"
|
|
122
|
+
if processing_bit_depth == 8 and np.dtype(info["dtype"]).itemsize == 2
|
|
123
|
+
else info["dtype"]
|
|
124
|
+
),
|
|
125
|
+
"frame_start": frame_start,
|
|
126
|
+
"frame_stop": stop,
|
|
127
|
+
"chunk_frames": chunk_frames,
|
|
128
|
+
"chunk_overlap_frames": chunk_overlap_frames,
|
|
129
|
+
"shape": None,
|
|
130
|
+
"chunks": [],
|
|
131
|
+
}
|
|
132
|
+
step = chunk_frames - chunk_overlap_frames
|
|
133
|
+
chunk_starts = range(frame_start, stop, step)
|
|
134
|
+
total_chunks = (stop - frame_start + step - 1) // step
|
|
135
|
+
for chunk_number, first in enumerate(
|
|
136
|
+
progress(
|
|
137
|
+
chunk_starts,
|
|
138
|
+
desc="Writing TIFF chunks",
|
|
139
|
+
total=total_chunks,
|
|
140
|
+
unit="chunk",
|
|
141
|
+
)
|
|
142
|
+
):
|
|
143
|
+
last = min(first + chunk_frames, stop)
|
|
144
|
+
data = read_frames(source, first, last)
|
|
145
|
+
data = data[:, y_slice, x_slice]
|
|
146
|
+
data = to_processing_bit_depth(data, processing_bit_depth)
|
|
147
|
+
if manifest["shape"] is None:
|
|
148
|
+
manifest["shape"] = [stop - frame_start, data.shape[1], data.shape[2]]
|
|
149
|
+
name = f"chunk_{chunk_number:06d}.tif"
|
|
150
|
+
tifffile.imwrite(
|
|
151
|
+
chunks_dir / name,
|
|
152
|
+
data,
|
|
153
|
+
compression="deflate",
|
|
154
|
+
predictor=True,
|
|
155
|
+
metadata={"axes": "ZYX"},
|
|
156
|
+
)
|
|
157
|
+
manifest["chunks"].append(
|
|
158
|
+
{
|
|
159
|
+
"file": f"chunks/{name}",
|
|
160
|
+
"frame_start": first,
|
|
161
|
+
"frame_stop": last,
|
|
162
|
+
"shape": list(data.shape),
|
|
163
|
+
}
|
|
164
|
+
)
|
|
165
|
+
del data
|
|
166
|
+
|
|
167
|
+
(target / "manifest.json").write_text(
|
|
168
|
+
json.dumps(manifest, indent=2), encoding="utf-8"
|
|
169
|
+
)
|
|
170
|
+
return manifest
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def iter_blocks(store: Path) -> Iterator[tuple[int, np.ndarray]]:
|
|
174
|
+
manifest_path = store / "manifest.json"
|
|
175
|
+
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
|
176
|
+
if manifest.get("format") != "nanoobjectdetection-2.0-chunks-v1":
|
|
177
|
+
raise ValueError(f"Unsupported image store: {store}")
|
|
178
|
+
for chunk in progress(
|
|
179
|
+
manifest["chunks"],
|
|
180
|
+
desc="Reading image chunks",
|
|
181
|
+
total=len(manifest["chunks"]),
|
|
182
|
+
unit="chunk",
|
|
183
|
+
):
|
|
184
|
+
yield (
|
|
185
|
+
int(chunk["frame_start"]),
|
|
186
|
+
read_frames(store / chunk["file"], 0, int(chunk["shape"][0])),
|
|
187
|
+
)
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
"""Deterministic synthetic trajectories for numerical pipeline tests."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def brownian_trajectories(
|
|
10
|
+
particles: int = 3,
|
|
11
|
+
frames: int = 200,
|
|
12
|
+
diffusion_um2_s: float = 1.0,
|
|
13
|
+
fps: float = 300.0,
|
|
14
|
+
seed: int = 42,
|
|
15
|
+
drift_per_frame: tuple[float, float] = (0.0, 0.0),
|
|
16
|
+
) -> pd.DataFrame:
|
|
17
|
+
"""Generate linked trajectories in pixel coordinates."""
|
|
18
|
+
rng = np.random.default_rng(seed)
|
|
19
|
+
rows = []
|
|
20
|
+
step = np.sqrt(2 * diffusion_um2_s / fps)
|
|
21
|
+
for particle in range(particles):
|
|
22
|
+
positions = np.zeros((frames, 2))
|
|
23
|
+
positions[0] = rng.normal(0, 1, 2)
|
|
24
|
+
positions[1:] = positions[0] + np.cumsum(rng.normal(0, step, (frames - 1, 2)), axis=0)
|
|
25
|
+
positions += np.arange(frames)[:, None] * drift_per_frame
|
|
26
|
+
for frame, (x, y) in enumerate(positions):
|
|
27
|
+
rows.append({"particle": particle, "frame": frame, "x": x, "y": y})
|
|
28
|
+
return pd.DataFrame(rows)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Trajectory linking for the independent pipeline."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import pandas as pd
|
|
6
|
+
import sys
|
|
7
|
+
import numpy as np
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def link(
|
|
11
|
+
detections: pd.DataFrame,
|
|
12
|
+
search_range: float,
|
|
13
|
+
memory: int = 1,
|
|
14
|
+
minimum_frames: int = 100,
|
|
15
|
+
adaptive_stop: float | None = None,
|
|
16
|
+
adaptive_step: float = 0.9,
|
|
17
|
+
) -> pd.DataFrame:
|
|
18
|
+
"""Link detections without importing any historical project module."""
|
|
19
|
+
try:
|
|
20
|
+
import trackpy as tp
|
|
21
|
+
from trackpy.linking.utils import SubnetOversizeException
|
|
22
|
+
except ImportError as exc:
|
|
23
|
+
raise RuntimeError(
|
|
24
|
+
"Trajectory linking requires 'trackpy', but it is not "
|
|
25
|
+
f"installed in the active interpreter ({sys.executable}). "
|
|
26
|
+
"Install it with: python -m pip install trackpy"
|
|
27
|
+
) from exc
|
|
28
|
+
tp.quiet()
|
|
29
|
+
required = {"frame", "x", "y"}
|
|
30
|
+
missing = required.difference(detections.columns)
|
|
31
|
+
if missing:
|
|
32
|
+
raise ValueError(f"Detections are missing columns: {sorted(missing)}")
|
|
33
|
+
if search_range <= 0:
|
|
34
|
+
raise ValueError("search_range must be positive")
|
|
35
|
+
if not 0 < adaptive_step < 1:
|
|
36
|
+
raise ValueError("adaptive_step must be between 0 and 1")
|
|
37
|
+
if adaptive_stop is None:
|
|
38
|
+
adaptive_stop = min(0.5, search_range * 0.25)
|
|
39
|
+
if not 0 < adaptive_stop < search_range:
|
|
40
|
+
raise ValueError("adaptive_stop must be positive and smaller than search_range")
|
|
41
|
+
try:
|
|
42
|
+
linked = tp.link_df(
|
|
43
|
+
detections.sort_values("frame"),
|
|
44
|
+
search_range=search_range,
|
|
45
|
+
memory=memory,
|
|
46
|
+
adaptive_stop=adaptive_stop,
|
|
47
|
+
adaptive_step=adaptive_step,
|
|
48
|
+
)
|
|
49
|
+
except SubnetOversizeException as exc:
|
|
50
|
+
raise RuntimeError(
|
|
51
|
+
"Trackpy could not resolve a dense linking subnetwork even after "
|
|
52
|
+
f"adaptive search-range reduction to {adaptive_stop:g} pixels. "
|
|
53
|
+
"Reduce search_range, increase particle_minmass, or inspect "
|
|
54
|
+
"overlapping detections."
|
|
55
|
+
) from exc
|
|
56
|
+
lengths = linked.groupby("particle")["frame"].transform("size")
|
|
57
|
+
return linked[lengths >= minimum_frames].reset_index(drop=True)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def remove_near_stationary(
|
|
61
|
+
detections: pd.DataFrame,
|
|
62
|
+
stationary: pd.DataFrame,
|
|
63
|
+
distance: float,
|
|
64
|
+
) -> pd.DataFrame:
|
|
65
|
+
"""Remove detections inside the 1.0 stationary-particle no-go areas."""
|
|
66
|
+
if distance < 0:
|
|
67
|
+
raise ValueError("distance must be non-negative")
|
|
68
|
+
if stationary.empty or detections.empty:
|
|
69
|
+
return detections.copy()
|
|
70
|
+
centers = stationary.groupby("particle")[["x", "y"]].mean().to_numpy()
|
|
71
|
+
points = detections[["x", "y"]].to_numpy()
|
|
72
|
+
keep = np.ones(len(points), dtype=bool)
|
|
73
|
+
for center in centers:
|
|
74
|
+
keep &= np.hypot(points[:, 0] - center[0], points[:, 1] - center[1]) > distance
|
|
75
|
+
return detections.loc[keep].reset_index(drop=True)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Trajectory filtering and splitting compatible with the 1.0 workflow."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def filter_minimum_frames(
|
|
10
|
+
trajectories: pd.DataFrame, minimum_frames: int
|
|
11
|
+
) -> pd.DataFrame:
|
|
12
|
+
"""Keep trajectories with at least ``minimum_frames`` measured positions."""
|
|
13
|
+
if minimum_frames < 1:
|
|
14
|
+
raise ValueError("minimum_frames must be positive")
|
|
15
|
+
if trajectories.empty:
|
|
16
|
+
return trajectories.copy()
|
|
17
|
+
lengths = trajectories.groupby("particle")["frame"].transform("size")
|
|
18
|
+
return trajectories.loc[lengths >= minimum_frames].reset_index(drop=True)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def _interpolated_mass(group: pd.DataFrame) -> pd.Series:
|
|
22
|
+
indexed = group.sort_values("frame").set_index("frame")["mass"].astype(float)
|
|
23
|
+
full = indexed.reindex(range(int(indexed.index.min()), int(indexed.index.max()) + 1))
|
|
24
|
+
return full.interpolate(method="nearest").bfill().ffill()
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def split_intensity_jumps(
|
|
28
|
+
trajectories: pd.DataFrame,
|
|
29
|
+
relative_step: float,
|
|
30
|
+
minimum_frames: int,
|
|
31
|
+
) -> pd.DataFrame:
|
|
32
|
+
"""Split tracks at large relative mass jumps and discard short fragments."""
|
|
33
|
+
if relative_step <= 0:
|
|
34
|
+
raise ValueError("relative_step must be positive")
|
|
35
|
+
required = {"particle", "frame", "mass"}
|
|
36
|
+
missing = required.difference(trajectories.columns)
|
|
37
|
+
if missing:
|
|
38
|
+
raise ValueError(f"Trajectories are missing columns: {sorted(missing)}")
|
|
39
|
+
|
|
40
|
+
result = trajectories.copy().sort_values(["particle", "frame"])
|
|
41
|
+
next_particle = int(result["particle"].max()) + 1 if not result.empty else 0
|
|
42
|
+
for particle, group in list(result.groupby("particle", sort=False)):
|
|
43
|
+
mass = _interpolated_mass(group)
|
|
44
|
+
smooth = mass.rolling(3, center=True, min_periods=1).median()
|
|
45
|
+
previous = smooth.shift(1)
|
|
46
|
+
relative = (smooth - previous).abs() / ((smooth + previous.abs()) / 2.0)
|
|
47
|
+
split_frames = relative.index[relative > relative_step].tolist()
|
|
48
|
+
for frame in split_frames:
|
|
49
|
+
mask = (result["particle"] == particle) & (result["frame"] >= frame)
|
|
50
|
+
result.loc[mask, "particle"] = next_particle
|
|
51
|
+
next_particle += 1
|
|
52
|
+
particle = next_particle - 1
|
|
53
|
+
return filter_minimum_frames(result, minimum_frames)
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def split_long_trajectories(
|
|
57
|
+
trajectories: pd.DataFrame,
|
|
58
|
+
maximum_frames: int | None,
|
|
59
|
+
minimum_frames: int,
|
|
60
|
+
keep_tail: bool = False,
|
|
61
|
+
) -> pd.DataFrame:
|
|
62
|
+
"""Split long tracks into bounded contiguous frame segments."""
|
|
63
|
+
if maximum_frames is None:
|
|
64
|
+
return trajectories.copy()
|
|
65
|
+
if maximum_frames < minimum_frames:
|
|
66
|
+
raise ValueError("maximum_frames must be at least minimum_frames")
|
|
67
|
+
result_parts: list[pd.DataFrame] = []
|
|
68
|
+
next_particle = int(trajectories["particle"].max()) + 1 if not trajectories.empty else 0
|
|
69
|
+
for _, group in trajectories.sort_values(["particle", "frame"]).groupby("particle"):
|
|
70
|
+
group = group.copy()
|
|
71
|
+
for start in range(0, len(group), maximum_frames):
|
|
72
|
+
part = group.iloc[start : start + maximum_frames].copy()
|
|
73
|
+
if len(part) < minimum_frames and not keep_tail:
|
|
74
|
+
continue
|
|
75
|
+
if start:
|
|
76
|
+
part["particle"] = next_particle
|
|
77
|
+
next_particle += 1
|
|
78
|
+
result_parts.append(part)
|
|
79
|
+
if not result_parts:
|
|
80
|
+
return trajectories.iloc[0:0].copy()
|
|
81
|
+
return pd.concat(result_parts, ignore_index=True)
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
"""Ultra-uniform particle hypothesis filter (z-score based, as in 1.0 PostProcessing)."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import numpy as np
|
|
6
|
+
import pandas as pd
|
|
7
|
+
|
|
8
|
+
# 99 % two-sided confidence level
|
|
9
|
+
DEFAULT_Z_MAX = 2.576
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
def _spread(values: np.ndarray) -> float:
|
|
13
|
+
return float(np.sqrt(np.mean((values - np.median(values)) ** 2)))
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def _remove_outliers(data: pd.DataFrame, column: str, roll_area: int, z_max: float) -> pd.DataFrame:
|
|
17
|
+
"""Iteratively drop rows whose z-score (median-centred, rolling spread) reaches z_max."""
|
|
18
|
+
while len(data):
|
|
19
|
+
spread = data[column].rolling(roll_area, min_periods=1, center=True).apply(_spread, raw=True)
|
|
20
|
+
z = (data[column] - data[column].median()).abs() / spread
|
|
21
|
+
if z.max() < z_max:
|
|
22
|
+
break
|
|
23
|
+
data = data[z < z_max]
|
|
24
|
+
return data
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
def ultra_uniform_mask(
|
|
28
|
+
estimates: pd.DataFrame,
|
|
29
|
+
z_max: float = DEFAULT_Z_MAX,
|
|
30
|
+
diameter_column: str = "diameter_nm",
|
|
31
|
+
diffusion_column: str = "diffusion_um2_s",
|
|
32
|
+
order_column: str = "valid_frames",
|
|
33
|
+
) -> pd.Series:
|
|
34
|
+
"""Return a boolean mask of particles that fit a single uniform particle species.
|
|
35
|
+
|
|
36
|
+
Particles are ordered by trajectory precision, then diameter and diffusion are
|
|
37
|
+
filtered one after the other (diffusion on the particles that survived the
|
|
38
|
+
diameter step), exactly like ForceUltraUniformParticle in 1.0.
|
|
39
|
+
"""
|
|
40
|
+
valid = estimates[estimates[diameter_column].notna() & estimates[diffusion_column].notna()]
|
|
41
|
+
num_particles = len(valid)
|
|
42
|
+
if num_particles < 10:
|
|
43
|
+
return pd.Series(True, index=estimates.index)
|
|
44
|
+
roll_area = 100 if num_particles >= 100 else num_particles - 2
|
|
45
|
+
data = valid[[diameter_column, diffusion_column, order_column]].sort_values(order_column)
|
|
46
|
+
data = _remove_outliers(data, diameter_column, roll_area, z_max)
|
|
47
|
+
data = _remove_outliers(data, diffusion_column, roll_area, z_max)
|
|
48
|
+
keep = pd.Series(False, index=estimates.index)
|
|
49
|
+
keep.loc[data.index] = True
|
|
50
|
+
# Rows without a valid estimate were already rejected earlier and are left untouched
|
|
51
|
+
keep.loc[~estimates.index.isin(valid.index)] = True
|
|
52
|
+
return keep
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: nanoobjectdetection
|
|
3
|
+
Version: 2.0.0
|
|
4
|
+
Summary: Particle localization and diffusion analysis for TIFF image stacks
|
|
5
|
+
Author: Tim Stechel, Ronny Förster
|
|
6
|
+
License: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/knobold/NanoObjectDetection
|
|
8
|
+
Project-URL: Repository, https://github.com/knobold/NanoObjectDetection
|
|
9
|
+
Project-URL: Issues, https://github.com/knobold/NanoObjectDetection/issues
|
|
10
|
+
Keywords: nanoparticles,microscopy,diffusion,tracking,stokes-einstein,trackpy,tiff
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Intended Audience :: Science/Research
|
|
13
|
+
Classifier: Topic :: Scientific/Engineering :: Bio-Informatics
|
|
14
|
+
Classifier: Topic :: Scientific/Engineering :: Image Processing
|
|
15
|
+
Classifier: Topic :: Scientific/Engineering :: Physics
|
|
16
|
+
Classifier: Programming Language :: Python :: 3
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
19
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Description-Content-Type: text/markdown
|
|
22
|
+
Requires-Dist: numpy
|
|
23
|
+
Requires-Dist: pandas
|
|
24
|
+
Requires-Dist: scipy
|
|
25
|
+
Requires-Dist: tifffile
|
|
26
|
+
Requires-Dist: trackpy
|
|
27
|
+
Requires-Dist: tqdm
|
|
28
|
+
Requires-Dist: matplotlib
|
|
29
|
+
Requires-Dist: numba
|
|
30
|
+
Provides-Extra: gpu
|
|
31
|
+
Requires-Dist: cupy-cuda12x; extra == "gpu"
|
|
32
|
+
|
|
33
|
+
# NanoObjectDetection 2.0
|
|
34
|
+
|
|
35
|
+
This is a new Version of Ronny Foersters Package. The old Package had lots of issues with type safety and RAM usage. This new implementation aims to improve RAM usage and enable us to easily develop new features.
|
|
36
|
+
|
|
37
|
+
NanoObjectDetection analyzes TIFF image stacks of diffusing particles. It
|
|
38
|
+
localizes particles, links detections into trajectories, estimates diffusion
|
|
39
|
+
and hydrodynamic diameter, and exports analysis tables and plots. Version 2.0
|
|
40
|
+
keeps compatibility with the 1.0 parameter format and adds blockwise
|
|
41
|
+
processing and optional CUDA acceleration.
|
|
42
|
+
|
|
43
|
+
The source checkout also contains the original 1.0 implementation in
|
|
44
|
+
[`nanoobjectdetection_legacy/`](./nanoobjectdetection_legacy/); the installable
|
|
45
|
+
distribution packages the modern pipeline under the `nanoobjectdetection`
|
|
46
|
+
package. For full parameter descriptions,
|
|
47
|
+
scientific methods, compatibility details, limitations, and output-file
|
|
48
|
+
definitions, see the
|
|
49
|
+
[2.0 documentation](./NANOOBJECTDETECTION_2_0_DOCUMENTATION.md).
|
|
50
|
+
|
|
51
|
+
## Features
|
|
52
|
+
|
|
53
|
+
- TIFF stack input with spatial and frame-range ROI selection.
|
|
54
|
+
- Camera-offset, laser-fluctuation, static-background, and optional SNR
|
|
55
|
+
preprocessing.
|
|
56
|
+
- Trackpy particle localization and trajectory linking, including optional
|
|
57
|
+
stitching for repeatedly lost single-particle detections.
|
|
58
|
+
- Fixed-particle filtering, trajectory quality checks, and global drift
|
|
59
|
+
correction.
|
|
60
|
+
- Mean-squared displacement analysis, diffusion estimation, and
|
|
61
|
+
Stokes-Einstein hydrodynamic diameter calculations.
|
|
62
|
+
- Optional channel hindrance correction, including position-aware correction.
|
|
63
|
+
- Single Particle Tracking using stitched Trajectories
|
|
64
|
+
- RAM saving options: Single-stack and chunked execution modes, plus optional CUDA preprocessing, Optional Conversion of pixels to 8bit depth, 16bit is probably overkill.
|
|
65
|
+
- CSV tables, compatibility output names, run configuration, and plots.
|
|
66
|
+
|
|
67
|
+
## New Features
|
|
68
|
+
|
|
69
|
+
- Try to implement them on your own! AI is powerful and easy too use. You can then try to submit a merge request here.
|
|
70
|
+
|
|
71
|
+
## Issues
|
|
72
|
+
|
|
73
|
+
- 1st Try to solve the issue yourself with AI
|
|
74
|
+
-> if its a bug caused by this repo, not your local installation open an Issue or write me a detailed Bug report on Slack. Paste console logs or anything that helps me solve your issue.
|
|
75
|
+
|
|
76
|
+
## Installation
|
|
77
|
+
|
|
78
|
+
### From PyPI (Recommended)
|
|
79
|
+
|
|
80
|
+
Install the latest release directly via pip:
|
|
81
|
+
|
|
82
|
+
```bash
|
|
83
|
+
python -m pip install --upgrade pip
|
|
84
|
+
python -m pip install nanoobjectdetection
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For GPU acceleration with CUDA 12.x:
|
|
88
|
+
|
|
89
|
+
```bash
|
|
90
|
+
python -m pip install "nanoobjectdetection[gpu]"
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
### From Source Checkout
|
|
94
|
+
|
|
95
|
+
Download this repo as a `.zip` file and unpack it, or clone via Git.
|
|
96
|
+
From the repository folder, activate your environment and install:
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
python -m pip install --upgrade pip
|
|
100
|
+
python -m pip install .
|
|
101
|
+
```
|
|
102
|
+
|
|
103
|
+
To install an editable development copy instead, use `python -m pip install -e .` from the repository folder.
|
|
104
|
+
|
|
105
|
+
The installation adds the `nanoobjectdetection` CLI command. It can be run from
|
|
106
|
+
any working directory:
|
|
107
|
+
|
|
108
|
+
```bash
|
|
109
|
+
nanoobjectdetection --input-file "path/to/stack.tif" --output-dir "path/to/output"
|
|
110
|
+
```
|
|
111
|
+
|
|
112
|
+
### Python Example & Scripting
|
|
113
|
+
|
|
114
|
+
The recommended way to use the package is to adjust analysis parameters in an example script. Run the included [`example/example.py`](./example/example.py) from any directory:
|
|
115
|
+
|
|
116
|
+
```powershell
|
|
117
|
+
python "example/example.py" `
|
|
118
|
+
--input-file "path/to/my_stack.tif" `
|
|
119
|
+
--output-dir "path/to/my_analysis" `
|
|
120
|
+
--background-stack-storage disk `
|
|
121
|
+
--processing-bit-depth 8 `
|
|
122
|
+
--dimension all
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
The script imports `run` directly from the installed package:
|
|
126
|
+
```python
|
|
127
|
+
from nanoobjectdetection import run
|
|
128
|
+
```
|
|
129
|
+
Its `PARAMETERS` dictionary and processing settings near the top of
|
|
130
|
+
`example/example.py` expose the full analysis configuration for editing. Omit
|
|
131
|
+
`--parameter-file` to use those settings; optionally add
|
|
132
|
+
`--parameter-file "path/to/parameter.json"` to use a legacy JSON instead.
|
|
133
|
+
Input/output paths can be configured directly inside the script or passed as CLI options.
|
|
134
|
+
Run `python example/example.py --help` for all options.
|
|
135
|
+
|
|
136
|
+
### Optional GPU preprocessing
|
|
137
|
+
|
|
138
|
+
GPU mode requires a supported NVIDIA GPU, a compatible CUDA runtime, and the
|
|
139
|
+
matching CuPy package. For CUDA 12.x, install the package's GPU extra:
|
|
140
|
+
|
|
141
|
+
```bash
|
|
142
|
+
python -m pip install ".[gpu]"
|
|
143
|
+
```
|
|
144
|
+
|
|
145
|
+
If your CUDA version differs, install the CuPy package matching your CUDA
|
|
146
|
+
runtime instead. The pipeline reports an error if GPU mode is requested but a
|
|
147
|
+
usable CUDA device is unavailable. Trackpy localization, linking, and MSD
|
|
148
|
+
fitting remain CPU operations.
|
|
149
|
+
|
|
150
|
+
## Run from an IDE
|
|
151
|
+
|
|
152
|
+
The source `main.py` defaults to the included example TIFF and can be run
|
|
153
|
+
directly from VS Code, PyCharm, or another Python IDE:
|
|
154
|
+
|
|
155
|
+
1. Open the repository folder in your IDE.
|
|
156
|
+
2. Select the Python interpreter from the `.venv` environment created above.
|
|
157
|
+
In VS Code, use **Python: Select Interpreter**; in PyCharm, select the
|
|
158
|
+
project interpreter in the Python interpreter settings.
|
|
159
|
+
3. Run `main.py` with the IDE's **Run Python File** or **Run** action to
|
|
160
|
+
analyze the included example.
|
|
161
|
+
|
|
162
|
+
For your own data, use the installed command with a parameter JSON rather than
|
|
163
|
+
editing `main.py`. Review camera calibration, acquisition rate, temperature,
|
|
164
|
+
viscosity, channel dimensions, ROI, and Trackpy settings in that JSON.
|
|
165
|
+
|
|
166
|
+
## Run from a terminal
|
|
167
|
+
|
|
168
|
+
Run an installed analysis from any folder in PowerShell:
|
|
169
|
+
|
|
170
|
+
```powershell
|
|
171
|
+
nanoobjectdetection `
|
|
172
|
+
--parameter-file "C:\data\parameter.json" `
|
|
173
|
+
--input-file "C:\data\my_stack.tif" `
|
|
174
|
+
--output-dir "C:\data\my_analysis" `
|
|
175
|
+
--processing-mode single_stack `
|
|
176
|
+
--background-stack-storage disk `
|
|
177
|
+
--processing-bit-depth 8
|
|
178
|
+
```
|
|
179
|
+
|
|
180
|
+
To install the package from another folder or drive, use its repository path:
|
|
181
|
+
|
|
182
|
+
```powershell
|
|
183
|
+
python -m pip install "D:\Python\NanoObjectDetection"
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
The source script can also be run with options from the repository folder:
|
|
187
|
+
|
|
188
|
+
```powershell
|
|
189
|
+
python .\main.py `
|
|
190
|
+
--input-file "C:\data\my_stack.tif" `
|
|
191
|
+
--output-dir "C:\data\my_analysis" `
|
|
192
|
+
--processing-mode single_stack `
|
|
193
|
+
--dimension 2d `
|
|
194
|
+
--background-stack-storage disk
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
To use a legacy-format parameter file:
|
|
198
|
+
|
|
199
|
+
```powershell
|
|
200
|
+
python .\main.py `
|
|
201
|
+
--parameter-file ".\example\parameter.json" `
|
|
202
|
+
--input-file ".\example\20_6µmKap_50Au_20_7C_100fps_5000fr_700us_V1j.tif" `
|
|
203
|
+
--output-dir ".\example\my_analysis" `
|
|
204
|
+
--processing-mode chunked
|
|
205
|
+
```
|
|
206
|
+
|
|
207
|
+
The input and output command-line paths override the corresponding paths in
|
|
208
|
+
the parameter JSON. If `--parameter-file` is omitted, the analysis settings
|
|
209
|
+
defined in `main.py` are used.
|
|
210
|
+
|
|
211
|
+
Available command-line options:
|
|
212
|
+
|
|
213
|
+
| Option | Purpose |
|
|
214
|
+
|---|---|
|
|
215
|
+
| `--processing-mode single_stack` | Read frames directly from the TIFF; this is the default. |
|
|
216
|
+
| `--processing-mode chunked` | Store/process the selected stack in blocks, useful for large inputs. |
|
|
217
|
+
| `--background-stack-storage memory` | Keep the background stack used for subtraction in RAM (default). |
|
|
218
|
+
| `--background-stack-storage disk` | Use a temporary disk-backed stack; needs free space approximately equal to the selected-depth ROI stack. |
|
|
219
|
+
| `--processing-bit-depth 16` | Preserve the input pixel values (default). |
|
|
220
|
+
| `--processing-bit-depth 8` | For uint16 input, keep the high 8 bits (`value >> 8`) for stored/raw processing data; halves raw/background stack storage but discards low-bit precision. |
|
|
221
|
+
| `--dimension all` | Analyze x, y, and 2d trajectories; this is the default. |
|
|
222
|
+
| `--dimension x`, `--dimension y`, `--dimension 2d` | Calculate only the selected trajectory dimension. |
|
|
223
|
+
| `--gpu` | Enable GPU preprocessing; requires compatible CuPy/CUDA installation. |
|
|
224
|
+
| `--input-file PATH` | Select the TIFF stack to analyze. |
|
|
225
|
+
| `--output-dir PATH` | Select where results are written. |
|
|
226
|
+
| `--parameter-file PATH` | Load analysis settings from a legacy-format JSON file. |
|
|
227
|
+
|
|
228
|
+
Disk-backed background storage preserves the same calculation as memory
|
|
229
|
+
storage at the selected bit depth and removes its temporary stack file after
|
|
230
|
+
localization. It can use more disk I/O and run more slowly than keeping the
|
|
231
|
+
stack in RAM.
|
|
232
|
+
|
|
233
|
+
8-bit processing is opt-in and quantizes uint16 values by shifting right eight
|
|
234
|
+
bits; it is not equivalent to the default 16-bit analysis and can change
|
|
235
|
+
background subtraction and particle detections. It reduces raw and background
|
|
236
|
+
stack storage by about half, but not total process memory because processing
|
|
237
|
+
also uses float32 working arrays. For uint8 TIFF input, this option leaves the
|
|
238
|
+
pixel values unchanged.
|
|
239
|
+
|
|
240
|
+
Chunking reduces localization working-block size, but does not make the exact
|
|
241
|
+
stack-wide static-background calculation bounded-memory. When that
|
|
242
|
+
preprocessing option is enabled, the selected ROI stack must fit in RAM even
|
|
243
|
+
in chunked mode. Reduce the ROI or frame range if memory is insufficient.
|
|
244
|
+
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
See the [2.0 documentation](./NANOOBJECTDETECTION_2_0_DOCUMENTATION.md) for
|
|
248
|
+
the complete list of supported settings, generated files, assumptions, and
|
|
249
|
+
known limitations.
|