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,670 @@
1
+ """NanoObjectDetection 2.0: 1.0-compatible chunked/GPU analysis.
2
+ 1.0 - Ronny Foerster and Jisoo 2019,
3
+ 2.0 - Tim Stechel 10/2026
4
+ -- Added Chunked processing and GPU support
5
+ -- New Hindrance model with Position Dependence
6
+ -- Stitched trajectory for single particle detection"""
7
+
8
+ from __future__ import annotations
9
+
10
+ import argparse
11
+ import ctypes
12
+ import ctypes.wintypes
13
+ from copy import deepcopy
14
+ from dataclasses import asdict, replace
15
+ import json
16
+ import platform
17
+ try:
18
+ import resource
19
+ except ImportError:
20
+ resource = None
21
+ from pathlib import Path
22
+ import threading
23
+ import time
24
+ from typing import Literal, Sequence
25
+
26
+ from .legacy_config import (
27
+ load_parameter_file,
28
+ load_parameter_settings,
29
+ )
30
+ from .auto_parameters import trackpy_diameter
31
+ from .legacy_outputs import export_legacy_aliases
32
+ from .pipeline import AnalysisPipeline
33
+ from .storage import tiff_info
34
+
35
+ ## Processing parameters
36
+ PROCESSING_MODE = "single_stack" # "chunked" = image store processed in blocks (large stacks), "single_stack" = frame by frame from the TIFF
37
+ BACKGROUND_STACK_STORAGE = "memory" # "memory" = keep the background stack in RAM, "disk" = use a temporary disk-backed map
38
+ PROCESSING_BIT_DEPTH = 16 # 8 = keep the high byte of uint16 pixels; 16 = preserve input precision
39
+ ANALYSIS_DIMENSIONS = ("x", "y", "2d")
40
+ DimensionSelection = Literal["all", "x", "y", "2d"]
41
+ DEFAULT_PARAMETER_FILE = None # optional path to legacy parameter file
42
+ GPU_ENABLED = False # True = GPU preprocessing (needs CuPy/CUDA), False = CPU #currently no point in using gpu since trackpy is CPU-bound
43
+ CHUNK_FRAMES = int((5000+500)/3) # frames per block in chunked mode; lower if memory runs out
44
+ LOCATE_WORKERS = 0 # CPU processes for trackpy localization (the real bottleneck); 1 = serial, 0 = all cores, sensible: 4-8
45
+ CHUNK_OVERLAP_FRAMES = 20 # frames shared by neighbouring blocks; 0 <= value < CHUNK_FRAMES
46
+
47
+ ## Camera parameters
48
+ CAMERA_MICRONS_PER_PIXEL = 0.3467 # pixel size in the sample plane in um
49
+ NA = 0.25 # numerical aperture of the objective (typ. 0.1-1.4); used for PSF width (auto diameter/kernel) and Estimate_X "Theory"
50
+ LAMBDA = 532.0 # laser wavelength in nm; used together with NA (see NA)
51
+ FPS=100.0
52
+
53
+ ## Sample parameters
54
+ ESTIMATED_PARTICLE_SIZE=50.0 # estimated particle size in nm (should be roughly the nominal value - 10%)
55
+ TEMPERATURE_K = 293.85 # sample temperature in K (20 C = 293.15)
56
+ VISCOSITY = 9.846562548618592e-16 # solvent viscosity in legacy units (kg/(um*s)); water 20 C ~ 1e-15
57
+ CHANNEL_DIAMETER_NM = 20600.0 # channel/fibre inner diameter in nm; needed for hindrance correction (0 = off)
58
+
59
+ ## Trackpy Parameters
60
+ AUTO_TP = False # automatically determine trackpy parameters, if false must provide tp_diameter and tp_minmass, true requires NA and lambda to calculate the PSF width
61
+ ENHANCE_SNR = False
62
+ def estimate_tp_diameter(
63
+ numerical_aperture: float,
64
+ wavelength_nm: float,
65
+ pixel_size_um: float,
66
+ enhance_snr: bool = False,
67
+ ) -> int:
68
+ """Estimate Trackpy's odd feature diameter from the optical PSF in pixels."""
69
+ if numerical_aperture <= 0 or wavelength_nm <= 0 or pixel_size_um <= 0:
70
+ raise ValueError("NA, wavelength, and pixel size must be positive")
71
+ return trackpy_diameter(
72
+ numerical_aperture,
73
+ wavelength_nm,
74
+ pixel_size_um,
75
+ enhance_snr,
76
+ )
77
+ TP_DIAMETER = 7# estimate_tp_diameter(NA, LAMBDA, CAMERA_MICRONS_PER_PIXEL, ENHANCE_SNR) # only used if AUTO_TP is False #7 is sensible value
78
+ TP_MINMASS = 358 # trackpy minimum mass (only used if AUTO_TP is False)
79
+
80
+ ## Other Processing Parameters
81
+ CORRECT_DRIFT = True
82
+ SATURATED_PIXEL_VALUE = "No Saturation"
83
+ ESTIMATE_HINDRANCE = True # whether to estimate hindrance correction (True/False) needs TubeDiameter_nm
84
+ HINDERANCE_POSITION_AWARE = False # whether to consider the particle position within the channel for hindrance correction (True/False)
85
+
86
+ ## Trajectory filtering
87
+ MIN_TRAJECTORY_LENGTH = 5 # minimum number of frames for a trajectory to be considered valid
88
+ MAX_DIAMETER = 0 # maximum particle diameter in nm (trajectories with larger particles are discarded) 0=off
89
+ FORCE_ULTRA_UNIFORM = False # whether to force ultra uniform particle distribution (Z-score) (True/False)
90
+
91
+ ## Single particle stitching mode
92
+ SINGLE_PARTICLE_ENABLED = False # whether to enable single particle stitching mode (True/False)
93
+ SINGLE_PARTICLE_KEEP = "longest" # which stitched trajectory to analyse: "longest" = most detections, "brightest" = largest total mass, "all" = every stitched trajectory
94
+
95
+ PARAMETERS = {
96
+ # Keys marked "[unused]" are kept for 1.0 JSON compatibility but ignored by the 2.0 pipeline.
97
+ "Exp": {
98
+ "NA": NA, # numerical aperture of the objective (typ. 0.1-1.4); used for PSF width (auto diameter/kernel) and Estimate_X "Theory"
99
+ "lambda": LAMBDA, # laser wavelength in nm; used together with NA (see NA)
100
+ "fps": FPS, # camera frame rate in frames/s
101
+ "ExposureTime": 0.0007, # camera exposure time in s (must be <= 1/fps); used by Estimate_X "Theory"
102
+ "Microns_per_pixel": CAMERA_MICRONS_PER_PIXEL, # pixel size in the sample plane in um
103
+ "Temperature": TEMPERATURE_K, # sample temperature in K (20 C = 293.15)
104
+ "Viscosity": VISCOSITY, # solvent viscosity in legacy units (kg/(um*s))
105
+ "Viscosity_auto": 0, # [unused] 1 = compute viscosity from Temperature/solvent in 1.0
106
+ "solvent": "water", # [unused] solvent name for Viscosity_auto: "water" or "glycerol"
107
+ },
108
+ "Fiber": {
109
+ "Mode": "Gaussian", # [unused] illumination profile in 1.0: "Gaussian" or "Flat"
110
+ "Waist": 10.3, # [unused] laser waist in um
111
+ "TubeDiameter_nm": CHANNEL_DIAMETER_NM, # channel/fibre inner diameter in nm; needed for hindrance correction (0 = off)
112
+ "Center_y_px": "unknown", # channel centre row in px; "unknown" = estimate from image, or give a number
113
+ },
114
+ "File": {
115
+ "data_file_name": "", # image stack path; set dynamically from input_file
116
+ "data_folder_name": "", # [unused] data folder
117
+ "data_type": "tif_stack", # [unused] input format; 2.0 reads TIFF stacks only
118
+ "use_num_frame": "all", # [unused] use ROI.frame_min/frame_max instead
119
+ "DefaultParameterJsonFile": "", # [unused] 1.0 defaults file
120
+ "json": "", # [unused] path of the 1.0 parameter file
121
+ },
122
+ "Logger": {"level": "info"}, # [unused] 1.0 log level: "debug", "info", "warning", "error"
123
+ "ROI": {
124
+ "Apply": 1, # [unused] ROI is always applied; use large max values to disable
125
+ "Save": 0, # [unused] 1.0 option to save the cropped stack
126
+ "x_min": 0, # first column in px (0 = left edge)
127
+ "x_max": 100000, # last column in px; >= 1e9 = full width, larger than image is clipped
128
+ "y_min": 0, # first row in px (0 = top edge)
129
+ "y_max": 100000, # last row in px; larger than image is clipped
130
+ "frame_min": 0, # first frame to analyse
131
+ "frame_max": 6000, # last frame (exclusive); >= 1e6 = all frames
132
+ },
133
+ "PreProcessing": {
134
+ "Remove_CameraOffset": 1, # 1 = subtract camera offset, 0 = off
135
+ "Remove_Laserfluctuation": 0, # 1 = normalise per-frame laser intensity, 0 = off
136
+ "Remove_StaticBackground": 1, # 1 = subtract static background (global over all chunks), 0 = off
137
+ "BackgroundCores": 1, # [unused] CPU cores for background estimation in 1.0
138
+ "AllowNonPosValues": 0, # [unused] 1.0 option for negative pixels
139
+ "EnhanceSNR": ENHANCE_SNR, # 1 = SNR enhancement filter (then Trackpy's own preprocessing is skipped), 0 = off
140
+ "KernelSize": "auto", # Gaussian sigma (px) of the SNR filter: "auto" = PSF width 0.21*lambda/NA/pixel size, or a number such as 1.0
141
+ "ZNCC_min": 0.7, # ZNCC correlation threshold (0-1) for reference particles in the minmass calibration; sensible 0.6-0.8
142
+ },
143
+ "Help": { # automatic parameter helpers; only Bead brightness, Bead size and TryFrames are used in 2.0
144
+ "ROI": 0, # 1 = help choosing ROI
145
+ "Bead brightness": AUTO_TP, # 1 = calibrate tp_minmass automatically via ZNCC (result x0.9), 0 = use tp_minmass
146
+ "Bead size": AUTO_TP, # 1 = set tp_diameter from PSF theory (NA, lambda, pixel size), 0 = use tp_diameter
147
+ "TryFrames": 30, # number of evenly spaced frames sampled by the minmass calibration; more = more robust, slower
148
+ "Separation": 1, # 1 = auto-estimate tp_separation
149
+ "GuessLowestDiameter_nm": ESTIMATED_PARTICLE_SIZE, # smallest expected diameter in nm for the helpers
150
+ "Drift": CORRECT_DRIFT, # 1 = auto-estimate drift averaging
151
+ "Intensity Jump": 1, # 1 = auto-estimate intensity jump threshold
152
+ },
153
+ "Find": {
154
+ "tp_diameter": TP_DIAMETER, # Trackpy feature size in px; odd integer >= 3, slightly above the particle spot size
155
+ "tp_minmass": TP_MINMASS, # Trackpy minimum feature mass
156
+ "SaturatedPixelValue": SATURATED_PIXEL_VALUE, # "No Saturation" or raw pixel value (e.g. 255, 4095, 65535); spots reaching it are excluded from the MSD fit
157
+ "tp_separation": 20, # min distance between spots in px
158
+ "tp_percentile": 20, # Trackpy brightness percentile filter, 0-100
159
+ },
160
+ "Link": {
161
+ "Dark time": 1, # frames a particle may vanish and still be linked (Trackpy memory); 0 = none
162
+ "Dwell time stationary objects": 100, # min frames for an object to count as stationary
163
+ "Max displacement": 10, # max px jump between frames when linking moving particles
164
+ "Max displacement fix": 0.3, # max px jump when searching stationary objects
165
+ "Min tracking frames before drift": 10.0, # min trajectory length used for drift estimation
166
+ "Min_tracking_frames": MIN_TRAJECTORY_LENGTH, # min trajectory length kept after drift correction
167
+ },
168
+ "StationaryObjects": {
169
+ "Analyze fixed spots": 1, # 1 = find stationary objects and exclude their neighbourhood, 0 = off
170
+ "Min distance to stationary object": 20, # exclusion radius in px around stationary objects
171
+ },
172
+ "Split": {
173
+ "Max_traj_length": 1000000.0, # split trajectories longer than this many frames; >= 1e8 = never
174
+ "Max_traj_length_keep_tail": 0, # 1 = keep the short remainder after splitting, 0 = discard it
175
+ "IntensityJump": 0, # 1 = split trajectories at brightness jumps (clusters/aggregates), 0 = off
176
+ "Max rel median intensity step": 0.906, # relative brightness step that triggers a split, 0-1
177
+ "ParticleWithLongestTrajBeforeSplit": 976, # [unused] 1.0 bookkeeping value
178
+ },
179
+ "Drift": {
180
+ "Apply": CORRECT_DRIFT, # 1 = correct drift, 0 = off
181
+ "Avg frames": 13, # frames averaged for the global drift estimate (trackpy smoothing)
182
+ "LaminarFlow": 0, # [unused] 1 = per-position drift for laminar flow in 1.0
183
+ "Drift rolling window size": 5, # rolling window (frames) used to smooth the drift
184
+ "Min particle per block": 40.0, # min particles per block for a drift estimate
185
+ },
186
+ "SingleParticle": { # video contains only one particle: stitch track fragments together
187
+ "Enabled": SINGLE_PARTICLE_ENABLED, # 1 = link without minimum length, stitch fragments, switch drift correction off; 0 = normal multi-particle analysis
188
+ "Keep": SINGLE_PARTICLE_KEEP, # which stitched trajectory to analyse: "longest" = most detections, "brightest" = largest total mass, "all" = every stitched trajectory
189
+ "Max gap frames": 300, # longest dropout (frames) that may be bridged; sensible 50-1000
190
+ "Max distance px": 15.0, # allowed jump between the end of one fragment and the start of the next, px; sensible 5-30
191
+ "Distance growth px per sqrt frame": 0.5, # extra allowed jump per sqrt(gap frames) to account for diffusion during the dropout; 0 = fixed distance
192
+ "Max mass ratio": 3.0, # fragments are only joined if their median brightness (mass) agrees within this factor; >= 1, sensible 2-5
193
+ "Min fragment frames": 3, # fragments with fewer detections are treated as noise and ignored; sensible 3-10
194
+ },
195
+ "MSD": {
196
+ "Amount summands": 10, # [unused] 1.0 value for block-wise MSD averaging
197
+ "Amount lagtimes auto": 1, # 1 = optimise lag times per track (Michalet 2012; legacy behaviour), 0 = fixed lagtimes_max
198
+ "Estimate_X": "Exp", # localisation error used in lag-time optimisation: "Exp" = from MSD fit offset, "Theory" = from NA, lambda, ExposureTime (Michalet & Berglund)
199
+ "lagtimes_min": 1, # [unused] first lag time; always 1
200
+ "lagtimes_max": 3, # fixed number of lag times (>= 2) used when Amount lagtimes auto = 0
201
+ "EstimateHindranceFactor": ESTIMATE_HINDRANCE, # 1 = correct diameter for wall hindrance (needs TubeDiameter_nm), 0 = off
202
+ "EvalOnlyLongestTraj": 0, # [unused] 1 = evaluate only the longest trajectory in 1.0
203
+ "CheckBrownianMotion": 1, # 1 = reject tracks failing the Kolmogorov-Smirnov test, 0 = keep all
204
+ "KSMethod": "legacy", # KS p-value: "legacy" = 2*exp(-2*N*D^2) like 1.0, "exact" = full Kolmogorov series with small-sample correction
205
+ "KSMinSignificance": 0.01, # reject tracks with p below this; sensible 0.001-0.1, higher = stricter
206
+ "HindrancePositionAware": HINDERANCE_POSITION_AWARE, # 1 = position-dependent hindrance from y position (overrides EstimateHindranceFactor), 0 = off
207
+ },
208
+ "Time": {"DoRolling": 0, "Frames": 100, "skip_image_fac": 10}, # [unused] 1.0 time-resolved analysis
209
+ "PostProcessing": {
210
+ "MinimalBrightness": 0, # [unused] min brightness filter in 1.0
211
+ "MaxDiameter": MAX_DIAMETER, # discard diameters above this in nm; 0 = off
212
+ "ForceUltraUniform": FORCE_ULTRA_UNIFORM, # 1 = drop particles whose diameter/diffusion z-score vs. the median is >= 2.576 (ultra-uniform sample), 0 = off
213
+ },
214
+ "Plot": { # only SaveFolder is read by 2.0; 2.0 creates its own plots
215
+ "Background": 0, # [unused] 1 = plot background
216
+ "Laserfluctuation": 0, # [unused] 1 = plot laser fluctuation
217
+ "MSD_fit": 0, # [unused] 1 = plot MSD fits
218
+ "Histogramm": 1, # [unused] 1 = diameter histogram
219
+ "Histogramm_abs_or_rel": "both", # [unused] "abs", "rel" or "both"
220
+ "Histogramm_Fit_1_Particle": 1, # [unused] 1 = fit single-particle distribution
221
+ "Histogramm_time": 0, # [unused] 1 = histogram over time
222
+ "Histogramm_Bin_size_nm": 0.5, # [unused] bin width in nm
223
+ "Histogramm_Bins": 2592, # [unused] number of bins
224
+ "Histogramm_min_max_auto": 0, # [unused] 1 = automatic histogram range
225
+ "Histogramm_min": 10, # [unused] histogram minimum in nm
226
+ "Histogramm_max": 300.0, # [unused] histogram maximum in nm
227
+ "DiameterPDF": 0, # [unused] 1 = diameter PDF plot
228
+ "DiameterPDF_min_max_auto": 1, # [unused] 1 = automatic PDF range
229
+ "PDF_min": 0.0, # [unused] PDF minimum in nm
230
+ "PDF_max": 1000.0, # [unused] PDF maximum in nm
231
+ "PDF_show_fit": 0, # [unused] 1 = show PDF fit
232
+ "DiamOverTraj": 1, # [unused] 1 = diameter vs trajectory length plot
233
+ "DiamOverTraj_UseRawMass": "mean", # [unused] "mean", "median" or "max"
234
+ "fontsize": 14, # [unused] plot font size
235
+ "SaveFolder": "", # output folder; set dynamically from output_dir
236
+ "save_json": 1, # [unused] 1 = save plot properties as JSON
237
+ "save_data2csv": 1, # [unused] 2.0 always writes CSVs
238
+ "dpi": 300, # [unused] figure resolution
239
+ "SaveProperties": ( # [unused] plot property file
240
+ ),
241
+ },
242
+ "Animation": { # [unused] 1.0 animation settings
243
+ "x_min": "auto", # "auto" or px
244
+ "x_max": "auto", # "auto" or px
245
+ "y_min": "auto", # "auto" or px
246
+ "y_max": "auto", # "auto" or px
247
+ "gamma": 0.5, # display gamma
248
+ "frames_tot": 5, # number of frames animated
249
+ "accelerate": 1, # playback speed factor
250
+ },
251
+ }
252
+
253
+
254
+ def _memory_stats() -> tuple[float | None, float | None, float | None]:
255
+ if platform.system() == "Windows":
256
+ total, available, rss, _ = _windows_memory_stats()
257
+ return total, available, rss
258
+
259
+ meminfo = Path("/proc/meminfo")
260
+ status = Path("/proc/self/status")
261
+ if not meminfo.exists() or not status.exists():
262
+ return None, None, None
263
+
264
+ values = {}
265
+ for line in meminfo.read_text().splitlines():
266
+ key, value = line.split(":", 1)
267
+ values[key] = int(value.split()[0]) / 1024**2
268
+ rss = next(
269
+ (
270
+ int(line.split()[1]) / 1024**2
271
+ for line in status.read_text().splitlines()
272
+ if line.startswith("VmRSS:")
273
+ ),
274
+ None,
275
+ )
276
+ return values.get("MemTotal"), values.get("MemAvailable"), rss
277
+
278
+
279
+ def _windows_memory_stats() -> tuple[float, float, float, float]:
280
+ class MemoryStatusEx(ctypes.Structure):
281
+ _fields_ = [
282
+ ("dwLength", ctypes.wintypes.DWORD),
283
+ ("dwMemoryLoad", ctypes.wintypes.DWORD),
284
+ ("ullTotalPhys", ctypes.c_ulonglong),
285
+ ("ullAvailPhys", ctypes.c_ulonglong),
286
+ ("ullTotalPageFile", ctypes.c_ulonglong),
287
+ ("ullAvailPageFile", ctypes.c_ulonglong),
288
+ ("ullTotalVirtual", ctypes.c_ulonglong),
289
+ ("ullAvailVirtual", ctypes.c_ulonglong),
290
+ ("ullAvailExtendedVirtual", ctypes.c_ulonglong),
291
+ ]
292
+
293
+ class ProcessMemoryCounters(ctypes.Structure):
294
+ _fields_ = [
295
+ ("cb", ctypes.wintypes.DWORD),
296
+ ("PageFaultCount", ctypes.wintypes.DWORD),
297
+ ("PeakWorkingSetSize", ctypes.c_size_t),
298
+ ("WorkingSetSize", ctypes.c_size_t),
299
+ ("QuotaPeakPagedPoolUsage", ctypes.c_size_t),
300
+ ("QuotaPagedPoolUsage", ctypes.c_size_t),
301
+ ("QuotaPeakNonPagedPoolUsage", ctypes.c_size_t),
302
+ ("QuotaNonPagedPoolUsage", ctypes.c_size_t),
303
+ ("PagefileUsage", ctypes.c_size_t),
304
+ ("PeakPagefileUsage", ctypes.c_size_t),
305
+ ]
306
+
307
+ memory_status = MemoryStatusEx()
308
+ memory_status.dwLength = ctypes.sizeof(MemoryStatusEx)
309
+ kernel32 = ctypes.WinDLL("kernel32", use_last_error=True)
310
+ global_memory_status_ex = kernel32.GlobalMemoryStatusEx
311
+ global_memory_status_ex.argtypes = [ctypes.POINTER(MemoryStatusEx)]
312
+ global_memory_status_ex.restype = ctypes.wintypes.BOOL
313
+ if not global_memory_status_ex(ctypes.byref(memory_status)):
314
+ raise ctypes.WinError(ctypes.get_last_error())
315
+
316
+ counters = ProcessMemoryCounters()
317
+ counters.cb = ctypes.sizeof(ProcessMemoryCounters)
318
+ get_current_process = kernel32.GetCurrentProcess
319
+ get_current_process.argtypes = []
320
+ get_current_process.restype = ctypes.c_void_p
321
+ psapi = ctypes.WinDLL("psapi", use_last_error=True)
322
+ get_process_memory_info = psapi.GetProcessMemoryInfo
323
+ get_process_memory_info.argtypes = [
324
+ ctypes.c_void_p,
325
+ ctypes.POINTER(ProcessMemoryCounters),
326
+ ctypes.wintypes.DWORD,
327
+ ]
328
+ get_process_memory_info.restype = ctypes.wintypes.BOOL
329
+ if not get_process_memory_info(
330
+ get_current_process(),
331
+ ctypes.byref(counters),
332
+ counters.cb,
333
+ ):
334
+ raise ctypes.WinError(ctypes.get_last_error())
335
+
336
+ gib = 1024**3
337
+ return (
338
+ memory_status.ullTotalPhys / gib,
339
+ memory_status.ullAvailPhys / gib,
340
+ counters.WorkingSetSize / gib,
341
+ counters.PeakWorkingSetSize / gib,
342
+ )
343
+
344
+
345
+ def _memory_report() -> str:
346
+ total, available, rss = _memory_stats()
347
+ if total is None or available is None:
348
+ return "RAM stats unavailable on this platform."
349
+ process_usage = f", process RSS {rss:.1f} GB" if rss is not None else ""
350
+ return (
351
+ f"RAM: {available:.1f} GB available of {total:.1f} GB"
352
+ f"{process_usage}."
353
+ )
354
+
355
+
356
+ def _log_stage(stage: str) -> None:
357
+ print(f"[{time.strftime('%H:%M:%S')}] {stage} - {_memory_report()}", flush=True)
358
+
359
+
360
+ def _peak_rss_gib() -> float | None:
361
+ if platform.system() == "Windows":
362
+ return _windows_memory_stats()[3]
363
+ if resource is None:
364
+ return None
365
+ getrusage = getattr(resource, "getrusage", None)
366
+ rusage_self = getattr(resource, "RUSAGE_SELF", None)
367
+ if getrusage is None or rusage_self is None:
368
+ return None
369
+ return getrusage(rusage_self).ru_maxrss / 1024**2
370
+
371
+
372
+ def _watch_memory(stop: threading.Event, min_available_gb: float = 1.0) -> None:
373
+ warned = False
374
+ while not stop.wait(2):
375
+ _, available, _ = _memory_stats()
376
+ if available is not None and available < min_available_gb and not warned:
377
+ print(
378
+ "[LOW MEMORY] "
379
+ f"{_memory_report()} If the process disappears abruptly, it may "
380
+ "have been terminated by the operating system's out-of-memory "
381
+ "handling; check the system logs for details.",
382
+ flush=True,
383
+ )
384
+ warned = True
385
+
386
+
387
+ def _selected_dimensions(dimension: DimensionSelection) -> tuple[str, ...]:
388
+ if dimension == "all":
389
+ return ANALYSIS_DIMENSIONS
390
+ if dimension in ANALYSIS_DIMENSIONS:
391
+ return (dimension,)
392
+ raise ValueError("dimension must be one of: all, x, y, 2d")
393
+
394
+
395
+ def run(parameter_file: Path | str | None = None, *,
396
+ input_file: Path | str,
397
+ output_dir: Path | str,
398
+ processing_mode: str = PROCESSING_MODE,
399
+ gpu_enabled: bool = GPU_ENABLED,
400
+ background_stack_storage: str = BACKGROUND_STACK_STORAGE,
401
+ processing_bit_depth: int = PROCESSING_BIT_DEPTH,
402
+ dimension: DimensionSelection = "all",
403
+ parameter_settings: dict | None = None,
404
+ chunk_frames: int = CHUNK_FRAMES,
405
+ chunk_overlap_frames: int = CHUNK_OVERLAP_FRAMES,
406
+ locate_workers: int = LOCATE_WORKERS) -> dict:
407
+ if input_file is None:
408
+ raise ValueError("input_file is required")
409
+ if output_dir is None:
410
+ raise ValueError("output_dir is required")
411
+ input_file = Path(input_file)
412
+ output_dir = Path(output_dir)
413
+ if parameter_file is not None and parameter_settings is not None:
414
+ raise ValueError("Provide either parameter_file or parameter_settings, not both.")
415
+ dimensions = _selected_dimensions(dimension)
416
+ tiff = tiff_info(input_file)
417
+ shape = tiff["shape"]
418
+ frame_count = shape[0] if len(shape) > 2 else tiff["pages"]
419
+ height, width = shape[-2:]
420
+ _log_stage(
421
+ f"Starting analysis of {input_file} "
422
+ f"({frame_count} frames, {height} x {width} pixels)"
423
+ )
424
+ stop = threading.Event()
425
+ watcher = threading.Thread(target=_watch_memory, args=(stop,), daemon=True)
426
+ watcher.start()
427
+ try:
428
+ return _run_analysis(
429
+ parameter_file,
430
+ input_file=input_file,
431
+ output_dir=output_dir,
432
+ processing_mode=processing_mode,
433
+ gpu_enabled=gpu_enabled,
434
+ background_stack_storage=background_stack_storage,
435
+ processing_bit_depth=processing_bit_depth,
436
+ dimensions=dimensions,
437
+ parameter_settings=parameter_settings,
438
+ chunk_frames=chunk_frames,
439
+ chunk_overlap_frames=chunk_overlap_frames,
440
+ locate_workers=locate_workers,
441
+ )
442
+ except MemoryError:
443
+ print(f"[OUT OF MEMORY] {_memory_report()}", flush=True)
444
+ raise
445
+ finally:
446
+ stop.set()
447
+ watcher.join(timeout=3)
448
+ peak_rss = _peak_rss_gib()
449
+ if peak_rss is None:
450
+ print("[PEAK MEMORY] Peak process RSS unavailable on this platform.", flush=True)
451
+ else:
452
+ print(f"[PEAK MEMORY] Peak process RSS: {peak_rss:.2f} GiB", flush=True)
453
+
454
+
455
+ def _run_analysis(parameter_file: Path | str | None = None, *,
456
+ input_file: Path,
457
+ output_dir: Path,
458
+ processing_mode: str = PROCESSING_MODE,
459
+ gpu_enabled: bool = GPU_ENABLED,
460
+ background_stack_storage: str = BACKGROUND_STACK_STORAGE,
461
+ processing_bit_depth: int = PROCESSING_BIT_DEPTH,
462
+ dimensions: tuple[str, ...] = ANALYSIS_DIMENSIONS,
463
+ parameter_settings: dict | None = None,
464
+ chunk_frames: int = CHUNK_FRAMES,
465
+ chunk_overlap_frames: int = CHUNK_OVERLAP_FRAMES,
466
+ locate_workers: int = LOCATE_WORKERS) -> dict:
467
+ _log_stage("Loading configuration")
468
+ if parameter_file is None:
469
+ active_settings = PARAMETERS if parameter_settings is None else parameter_settings
470
+ config, settings = load_parameter_settings(active_settings, output_dir)
471
+ parameter_source = (
472
+ "default parameters" if parameter_settings is None else "in-memory settings"
473
+ )
474
+ else:
475
+ parameter_file = Path(parameter_file)
476
+ config, settings = load_parameter_file(parameter_file)
477
+ parameter_source = str(parameter_file.resolve())
478
+ config = replace(
479
+ config,
480
+ input_file=input_file,
481
+ output_dir=output_dir,
482
+ processing_mode=processing_mode,
483
+ gpu_enabled=gpu_enabled,
484
+ background_stack_storage=background_stack_storage,
485
+ processing_bit_depth=processing_bit_depth,
486
+ chunk_frames=chunk_frames,
487
+ chunk_overlap_frames=chunk_overlap_frames,
488
+ locate_workers=locate_workers,
489
+ )
490
+ pipeline = AnalysisPipeline(config)
491
+ _log_stage(f"Preparing image store ({processing_mode} mode)")
492
+ pipeline.prepare_store()
493
+ _log_stage("Localizing particles")
494
+ detections, detections_path = pipeline.localize()
495
+ search_range = float(settings["Link"].get("Max displacement", 10))
496
+ memory = int(settings["Link"].get("Dark time", 1))
497
+ _log_stage("Linking particle detections into trajectories")
498
+ trajectories, trajectories_path = pipeline.track(
499
+ detections, search_range=search_range, memory=memory
500
+ )
501
+ analyses = {}
502
+ plot_paths = {}
503
+ for dimension in dimensions:
504
+ _log_stage(f"Analyzing {dimension} trajectories and saving plots")
505
+ estimates, sizes_path = pipeline.analyze_trajectories(
506
+ trajectories,
507
+ dimension=dimension,
508
+ drift_window=int(settings["Drift"].get("Avg frames", 13)),
509
+ min_particles_for_drift=int(settings["Drift"].get("Min particle per block", 40)),
510
+ output_suffix="x" if dimension == "x" else dimension,
511
+ )
512
+ analyses[dimension] = {
513
+ "particle_sizes_path": str(sizes_path),
514
+ "particles_analyzed": len(estimates),
515
+ }
516
+ plot_paths[dimension] = dict(pipeline.plot_paths)
517
+ analyses[dimension]["plot_paths"] = plot_paths[dimension]
518
+ _log_stage("Writing result metadata")
519
+ export_legacy_aliases(config.output_dir)
520
+ effective_config = pipeline.config
521
+ effective_legacy_parameters = deepcopy(settings)
522
+ effective_legacy_parameters.setdefault("File", {})["data_file_name"] = str(
523
+ effective_config.input_file.resolve()
524
+ )
525
+ effective_legacy_parameters.setdefault("Plot", {})["SaveFolder"] = str(
526
+ effective_config.output_dir.resolve()
527
+ )
528
+ effective_legacy_parameters.setdefault("Find", {})["tp_diameter"] = (
529
+ effective_config.particle_diameter
530
+ )
531
+
532
+ resolved_config = asdict(effective_config)
533
+ resolved_config["input_file"] = str(effective_config.input_file.resolve())
534
+ resolved_config["output_dir"] = str(effective_config.output_dir.resolve())
535
+ resolved_config["resolved_channel_center_y_px"] = pipeline.channel_center_y_px
536
+ effective_legacy_parameters["Find"]["tp_minmass"] = (
537
+ effective_config.particle_minmass
538
+ )
539
+ exported_parameters = deepcopy(effective_legacy_parameters)
540
+ exported_parameters["NanoObjectDetection_2_0"] = resolved_config
541
+
542
+ parameter_export_path = config.output_dir / "parameters.json"
543
+ if (
544
+ parameter_file is not None
545
+ and parameter_file.resolve() == parameter_export_path.resolve()
546
+ ):
547
+ parameter_export_path = config.output_dir / "parameters_used.json"
548
+ parameter_export_path.write_text(
549
+ json.dumps(exported_parameters, indent=2), encoding="utf-8"
550
+ )
551
+
552
+ metadata = {
553
+ "pipeline_version": "2.0",
554
+ "compatibility": "NanoObjectDetection 1.0",
555
+ "parameter_file": parameter_source,
556
+ "processing_mode": processing_mode,
557
+ "background_stack_storage": background_stack_storage,
558
+ "processing_bit_depth": processing_bit_depth,
559
+ "analysis_dimensions": list(dimensions),
560
+ "gpu_enabled": gpu_enabled,
561
+ "chunk_frames": chunk_frames,
562
+ "chunk_overlap_frames": chunk_overlap_frames,
563
+ "global_background": (
564
+ str((config.output_dir / "global_background.npy").resolve())
565
+ if config.processing_mode == "chunked"
566
+ and config.remove_static_background
567
+ else None
568
+ ),
569
+ "input_file": str(config.input_file.resolve()),
570
+ "output_dir": str(config.output_dir.resolve()),
571
+ "parameters": {
572
+ "legacy": effective_legacy_parameters,
573
+ "resolved_2_0": resolved_config,
574
+ },
575
+ "legacy_parameters_export": str(parameter_export_path.resolve()),
576
+ "analyses": analyses,
577
+ "plot_paths": plot_paths,
578
+ }
579
+ config.output_dir.mkdir(parents=True, exist_ok=True)
580
+ (config.output_dir / "analysis_config.json").write_text(
581
+ json.dumps(metadata, indent=2), encoding="utf-8"
582
+ )
583
+ (config.output_dir / "results_manifest.json").write_text(
584
+ json.dumps(
585
+ {
586
+ "output_dir": str(config.output_dir.resolve()),
587
+ "files": sorted(
588
+ str(path.relative_to(config.output_dir))
589
+ for path in config.output_dir.rglob("*")
590
+ if path.is_file()
591
+ ),
592
+ "analyses": analyses,
593
+ "plot_paths": plot_paths,
594
+ },
595
+ indent=2,
596
+ ),
597
+ encoding="utf-8",
598
+ )
599
+ return {
600
+ "detections_path": str(detections_path),
601
+ "trajectories_path": str(trajectories_path),
602
+ "analyses": analyses,
603
+ "output_dir": str(config.output_dir),
604
+ "legacy_parameters_path": str(parameter_export_path),
605
+ }
606
+
607
+
608
+ def cli(argv: Sequence[str] | None = None) -> None:
609
+ start_time = time.time()
610
+ parser = argparse.ArgumentParser(
611
+ description="NanoObjectDetection: particle localization and diffusion analysis for TIFF image stacks."
612
+ )
613
+ parser.add_argument(
614
+ "--input-file",
615
+ type=Path,
616
+ required=True,
617
+ help="Path to the input TIFF stack.",
618
+ )
619
+ parser.add_argument(
620
+ "--output-dir",
621
+ type=Path,
622
+ required=True,
623
+ help="Directory where analysis results will be saved.",
624
+ )
625
+ parser.add_argument(
626
+ "--parameter-file",
627
+ type=Path,
628
+ default=DEFAULT_PARAMETER_FILE,
629
+ help="Optional 1.0-compatible parameter JSON file to load settings from.",
630
+ )
631
+ parser.add_argument("--processing-mode", choices=("chunked", "single_stack"),
632
+ default=PROCESSING_MODE)
633
+ parser.add_argument(
634
+ "--background-stack-storage",
635
+ choices=("memory", "disk"),
636
+ default=BACKGROUND_STACK_STORAGE,
637
+ help="Store the background stack in RAM or a temporary disk-backed map.",
638
+ )
639
+ parser.add_argument(
640
+ "--processing-bit-depth",
641
+ type=int,
642
+ choices=(8, 16),
643
+ default=PROCESSING_BIT_DEPTH,
644
+ help=(
645
+ "Working storage depth. 8-bit mode keeps the high byte of uint16 "
646
+ "pixels and reduces precision; default is 16."
647
+ ),
648
+ )
649
+ parser.add_argument("--dimension", choices=("all", *ANALYSIS_DIMENSIONS),
650
+ default="all",
651
+ help="Trajectory analysis dimension to calculate (default: all).")
652
+ parser.add_argument("--gpu", action="store_true")
653
+ args = parser.parse_args(argv)
654
+ if args.input_file is None or args.output_dir is None:
655
+ parser.error("Both --input-file and --output-dir are required.")
656
+ print(json.dumps(run(
657
+ args.parameter_file,
658
+ input_file=args.input_file,
659
+ output_dir=args.output_dir,
660
+ processing_mode=args.processing_mode,
661
+ gpu_enabled=args.gpu,
662
+ background_stack_storage=args.background_stack_storage,
663
+ processing_bit_depth=args.processing_bit_depth,
664
+ dimension=args.dimension,
665
+ ), indent=2))
666
+ print(f"Execution time: {time.time() - start_time} seconds")
667
+
668
+
669
+ if __name__ == "__main__":
670
+ cli()