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