dropdrop 1.1.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.
dropdrop/__init__.py ADDED
@@ -0,0 +1,16 @@
1
+ """DropDrop - Droplet and Inclusion Detection Pipeline."""
2
+
3
+ from .cache import CacheManager
4
+ from .config import load_config
5
+ from .pipeline import DropletInclusionPipeline
6
+ from .stats import DropletStatistics
7
+ from .ui import BaseWindow, InclusionEditor, Viewer
8
+
9
+ __all__ = [
10
+ "CacheManager",
11
+ "DropletInclusionPipeline",
12
+ "DropletStatistics",
13
+ "InclusionEditor",
14
+ "Viewer",
15
+ "load_config",
16
+ ]
dropdrop/cache.py ADDED
@@ -0,0 +1,133 @@
1
+ """Cache management for expensive computations."""
2
+
3
+ import hashlib
4
+ import json
5
+ import shutil
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import numpy as np
10
+
11
+
12
+ class CacheManager:
13
+ """Global LRU cache for expensive computations, stored in project root."""
14
+
15
+ def __init__(self, config, cache_dir=None):
16
+ cache_cfg = config.get("cache", {})
17
+ self.enabled = cache_cfg.get("enabled", True)
18
+ self.max_frames = cache_cfg.get("max_frames", 100)
19
+ # Cache in project root by default
20
+ if cache_dir:
21
+ self.cache_dir = Path(cache_dir)
22
+ else:
23
+ self.cache_dir = Path(__file__).parent.parent.parent / ".cache"
24
+ self.metadata_path = self.cache_dir / "metadata.json"
25
+ self.metadata = self._load_metadata()
26
+ self.config = config
27
+
28
+ def _load_metadata(self):
29
+ """Load cache metadata from disk."""
30
+ if self.metadata_path.exists():
31
+ try:
32
+ with open(self.metadata_path) as f:
33
+ return json.load(f)
34
+ except (json.JSONDecodeError, IOError):
35
+ return self._default_metadata()
36
+ return self._default_metadata()
37
+
38
+ def _default_metadata(self):
39
+ """Return default metadata structure."""
40
+ return {"version": "1.0", "config_hash": None, "frames": {}, "access_order": []}
41
+
42
+ def _save_metadata(self):
43
+ """Save cache metadata to disk."""
44
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
45
+ with open(self.metadata_path, "w") as f:
46
+ json.dump(self.metadata, f, indent=2)
47
+
48
+ def _enforce_lru(self):
49
+ """Remove oldest frames if over max_frames limit."""
50
+ while len(self.metadata["access_order"]) > self.max_frames:
51
+ oldest_key = self.metadata["access_order"].pop(0)
52
+ cache_file = self.cache_dir / f"{oldest_key}.npz"
53
+ if cache_file.exists():
54
+ cache_file.unlink()
55
+ self.metadata["frames"].pop(oldest_key, None)
56
+
57
+ def get_config_hash(self):
58
+ """Hash detection-related config keys that affect caching."""
59
+ keys = [
60
+ "cellpose_flow_threshold",
61
+ "cellpose_cellprob_threshold",
62
+ "min_droplet_diameter",
63
+ "max_droplet_diameter",
64
+ ]
65
+ data = {k: self.config.get(k) for k in keys}
66
+ return hashlib.sha256(json.dumps(data, sort_keys=True).encode()).hexdigest()[:16]
67
+
68
+ def _get_cache_key(self, source_filename):
69
+ """Generate cache key from source filename (not full path)."""
70
+ name = Path(source_filename).stem
71
+ return hashlib.sha256(name.encode()).hexdigest()[:16]
72
+
73
+ def is_valid(self, source_filename):
74
+ """Check if cache is valid for frame by source filename."""
75
+ if not self.enabled:
76
+ return False
77
+ current_hash = self.get_config_hash()
78
+ if self.metadata.get("config_hash") != current_hash:
79
+ return False
80
+ cache_key = self._get_cache_key(source_filename)
81
+ cache_file = self.cache_dir / f"{cache_key}.npz"
82
+ return cache_file.exists()
83
+
84
+ def load_frame(self, source_filename):
85
+ """Load cached data by source filename and update access order."""
86
+ cache_key = self._get_cache_key(source_filename)
87
+ cache_file = self.cache_dir / f"{cache_key}.npz"
88
+ data = np.load(cache_file, allow_pickle=True)
89
+
90
+ # Update LRU order
91
+ if cache_key in self.metadata["access_order"]:
92
+ self.metadata["access_order"].remove(cache_key)
93
+ self.metadata["access_order"].append(cache_key)
94
+ self._save_metadata()
95
+
96
+ return {
97
+ "min_projection": data["min_projection"],
98
+ "droplet_coords": list(data["droplet_coords"]),
99
+ }
100
+
101
+ def save_frame(self, source_filename, min_proj, droplet_coords):
102
+ """Save frame data by source filename and enforce LRU limit."""
103
+ self.cache_dir.mkdir(parents=True, exist_ok=True)
104
+ cache_key = self._get_cache_key(source_filename)
105
+ cache_file = self.cache_dir / f"{cache_key}.npz"
106
+
107
+ np.savez(
108
+ cache_file,
109
+ min_projection=min_proj,
110
+ droplet_coords=np.array(droplet_coords, dtype=object),
111
+ )
112
+
113
+ # Update metadata
114
+ self.metadata["config_hash"] = self.get_config_hash()
115
+ self.metadata["frames"][cache_key] = {
116
+ "source": str(source_filename),
117
+ "cached_at": datetime.now().isoformat(),
118
+ }
119
+
120
+ # Update LRU order
121
+ if cache_key in self.metadata["access_order"]:
122
+ self.metadata["access_order"].remove(cache_key)
123
+ self.metadata["access_order"].append(cache_key)
124
+
125
+ self._enforce_lru()
126
+ self._save_metadata()
127
+
128
+ def clear(self):
129
+ """Clear entire cache."""
130
+ if self.cache_dir.exists():
131
+ shutil.rmtree(self.cache_dir)
132
+ self.metadata = self._default_metadata()
133
+ print("Cache cleared.")
dropdrop/cli.py ADDED
@@ -0,0 +1,252 @@
1
+ """Command-line interface for DropDrop pipeline."""
2
+
3
+ import argparse
4
+ import sys
5
+ import tarfile
6
+ from datetime import datetime
7
+ from pathlib import Path
8
+
9
+ import pandas as pd
10
+
11
+ from .cache import CacheManager
12
+ from .config import load_config
13
+ from .pipeline import DropletInclusionPipeline
14
+ from .stats import DropletStatistics
15
+ from .ui import InclusionEditor, Viewer
16
+
17
+
18
+ def parse_settings(settings_str):
19
+ """Parse compact settings string.
20
+
21
+ Format: key=value,key=value
22
+ Keys: d[ilution], p[oisson], c[ount], l[abel]
23
+
24
+ Examples:
25
+ "d=1000,p=on,c=6.5e5,l=experiment1"
26
+ "dilution=500,poisson=off"
27
+ """
28
+ settings = {
29
+ "dilution": 500,
30
+ "poisson": True,
31
+ "count": 6.5e5,
32
+ "label": None,
33
+ }
34
+
35
+ if not settings_str:
36
+ return settings
37
+
38
+ key_map = {
39
+ "d": "dilution",
40
+ "dilution": "dilution",
41
+ "p": "poisson",
42
+ "poisson": "poisson",
43
+ "c": "count",
44
+ "count": "count",
45
+ "l": "label",
46
+ "label": "label",
47
+ }
48
+
49
+ for part in settings_str.split(","):
50
+ if "=" not in part:
51
+ continue
52
+ key, value = part.split("=", 1)
53
+ key = key_map.get(key.strip().lower(), key.strip().lower())
54
+
55
+ if key == "dilution":
56
+ settings["dilution"] = int(value)
57
+ elif key == "poisson":
58
+ settings["poisson"] = value.lower() in ("on", "yes", "true", "1")
59
+ elif key == "count":
60
+ settings["count"] = float(value)
61
+ elif key == "label":
62
+ settings["label"] = value.strip()
63
+
64
+ return settings
65
+
66
+
67
+ def prompt_settings():
68
+ """Interactive prompts for settings when --settings not provided."""
69
+ settings = {"dilution": 500, "poisson": True, "count": 6.5e5, "label": None}
70
+
71
+ print("\n--- Project Settings ---")
72
+
73
+ # Poisson analysis
74
+ use_poisson = input("Use Poisson analysis? [yes/no] (yes): ").strip().lower()
75
+ settings["poisson"] = use_poisson != "no"
76
+
77
+ if settings["poisson"]:
78
+ # Bead count
79
+ count_input = input("Stock count/uL [6.5e5]: ").strip()
80
+ if count_input:
81
+ try:
82
+ settings["count"] = float(count_input)
83
+ except ValueError:
84
+ print(f" Invalid value, using default: {settings['count']}")
85
+
86
+ # Dilution
87
+ dilution_input = input("Dilution factor [500]: ").strip()
88
+ if dilution_input:
89
+ try:
90
+ settings["dilution"] = int(dilution_input)
91
+ except ValueError:
92
+ print(f" Invalid value, using default: {settings['dilution']}")
93
+
94
+ # Label
95
+ label_input = input("Project label (optional, press Enter to skip): ").strip()
96
+ settings["label"] = label_input if label_input else None
97
+
98
+ print("------------------------\n")
99
+ return settings
100
+
101
+
102
+ def generate_project_name(settings):
103
+ """Generate project directory name from date and label."""
104
+ date_str = datetime.now().strftime("%Y%m%d")
105
+ if settings.get("label"):
106
+ return f"{date_str}_{settings['label']}"
107
+ return date_str
108
+
109
+
110
+ def main():
111
+ """Main entry point."""
112
+ parser = argparse.ArgumentParser(
113
+ description="Droplet and inclusion detection pipeline using Cellpose"
114
+ )
115
+
116
+ parser.add_argument(
117
+ "input_dir", type=str, help="Input directory containing z-stack images"
118
+ )
119
+
120
+ parser.add_argument(
121
+ "output_dir",
122
+ type=str,
123
+ nargs="?",
124
+ default=None,
125
+ help="Output directory (default: ./results/<date>_<label>)",
126
+ )
127
+
128
+ parser.add_argument(
129
+ "-s",
130
+ "--settings",
131
+ type=str,
132
+ default=None,
133
+ help='Compact settings: "d=1000,p=on,c=6.5e5,l=label" (d=dilution, p=poisson, c=count, l=label)',
134
+ )
135
+
136
+ viewer_group = parser.add_mutually_exclusive_group()
137
+ viewer_group.add_argument(
138
+ "--view", action="store_true", help="Enable interactive viewer after processing"
139
+ )
140
+ viewer_group.add_argument(
141
+ "-i",
142
+ "--interactive",
143
+ action="store_true",
144
+ help="Interactive inclusion correction mode",
145
+ )
146
+
147
+ parser.add_argument(
148
+ "-n",
149
+ "--number",
150
+ type=int,
151
+ default=None,
152
+ help="Process only the first N frames (for testing)",
153
+ )
154
+ parser.add_argument(
155
+ "--no-cache",
156
+ action="store_true",
157
+ help="Disable caching for this run",
158
+ )
159
+ parser.add_argument(
160
+ "--clear-cache",
161
+ action="store_true",
162
+ help="Clear cache before processing",
163
+ )
164
+ parser.add_argument(
165
+ "-z",
166
+ "--gzip",
167
+ action="store_true",
168
+ help="Archive project directory as .tar.gz after completion",
169
+ )
170
+ args = parser.parse_args()
171
+
172
+ # Check input directory exists
173
+ if not Path(args.input_dir).exists():
174
+ print(f"ERROR: Input directory '{args.input_dir}' does not exist")
175
+ sys.exit(1)
176
+
177
+ # Get settings (from --settings or interactive prompts)
178
+ if args.settings:
179
+ settings = parse_settings(args.settings)
180
+ else:
181
+ settings = prompt_settings()
182
+
183
+ # Determine output directory
184
+ if args.output_dir:
185
+ output_dir = Path(args.output_dir)
186
+ else:
187
+ project_name = generate_project_name(settings)
188
+ output_dir = Path("results") / project_name
189
+
190
+ # Store settings for later use
191
+ settings["input_dir"] = str(Path(args.input_dir).resolve())
192
+
193
+ # Initialize and run pipeline
194
+ print(f"Input directory: {args.input_dir}")
195
+ print(f"Output directory: {output_dir}")
196
+ if settings["poisson"]:
197
+ print(f"Poisson: ON (count={settings['count']:.2e}, dilution={settings['dilution']})")
198
+ else:
199
+ print("Poisson: OFF")
200
+ if args.number:
201
+ print(f"Frame limit: {args.number}")
202
+
203
+ # Create pipeline with visualization storage if viewer is requested
204
+ store_viz = args.view or args.interactive
205
+ use_cache = not args.no_cache
206
+ pipeline = DropletInclusionPipeline(store_visualizations=store_viz, use_cache=use_cache)
207
+
208
+ # Handle cache clear request
209
+ if args.clear_cache and pipeline.cache:
210
+ pipeline.cache.clear()
211
+
212
+ results = pipeline.run(args.input_dir, str(output_dir), frame_limit=args.number)
213
+
214
+ if results:
215
+ print("\nPipeline completed successfully!")
216
+
217
+ # Interactive editing mode
218
+ if args.interactive and pipeline.visualization_data:
219
+ print("\nLaunching interactive inclusion editor...")
220
+ editor = InclusionEditor(pipeline.visualization_data, results)
221
+ results = editor.run() # Update results with manual corrections
222
+
223
+ # Save updated results
224
+ df = pd.DataFrame(results)
225
+ csv_path = output_dir / "data.csv"
226
+ df.to_csv(csv_path, index=False)
227
+ print(f"Updated results saved to: {csv_path}")
228
+
229
+ # Always generate statistics (after any interactive corrections)
230
+ print("\nGenerating statistical analysis...")
231
+ csv_path = output_dir / "data.csv"
232
+ stats_module = DropletStatistics(csv_path, settings)
233
+ stats_module.run_analysis(str(output_dir))
234
+
235
+ # Launch viewer if requested (no editing, just viewing)
236
+ if args.view and pipeline.visualization_data:
237
+ print("\nLaunching interactive viewer...")
238
+ df = pd.DataFrame(results)
239
+ viewer = Viewer(pipeline.visualization_data, df)
240
+ viewer.run()
241
+
242
+ # Archive project if requested
243
+ if args.gzip:
244
+ archive_name = f"{output_dir}.tar.gz"
245
+ print(f"\nArchiving project to: {archive_name}")
246
+ with tarfile.open(archive_name, "w:gz") as tar:
247
+ tar.add(output_dir, arcname=output_dir.name)
248
+ print(f"Archive created: {archive_name}")
249
+
250
+
251
+ if __name__ == "__main__":
252
+ main()
dropdrop/config.py ADDED
@@ -0,0 +1,67 @@
1
+ """Configuration management."""
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ DEFAULT_CONFIG = {
7
+ # Cellpose parameters
8
+ "cellpose_flow_threshold": 0.4,
9
+ "cellpose_cellprob_threshold": 0.0,
10
+ # Erosion parameters
11
+ "erosion_pixels": 5,
12
+ # Inclusion detection parameters
13
+ "kernel_size": 7,
14
+ "tophat_threshold": 30,
15
+ "min_inclusion_area": 7,
16
+ "max_inclusion_area": 50,
17
+ "edge_buffer": 5,
18
+ # Droplet filtering
19
+ "min_droplet_diameter": 80,
20
+ "max_droplet_diameter": 200,
21
+ # Conversion factor
22
+ "px_to_um": 1.14,
23
+ # Cache settings
24
+ "cache": {
25
+ "enabled": True,
26
+ "max_frames": 100,
27
+ "strategy": "lru",
28
+ },
29
+ }
30
+
31
+
32
+ def load_config(config_path=None):
33
+ """Load configuration from JSON file or use defaults.
34
+
35
+ Args:
36
+ config_path: Path to config.json. If None, looks in current directory.
37
+
38
+ Returns:
39
+ dict: Configuration dictionary with defaults merged.
40
+ """
41
+ config = DEFAULT_CONFIG.copy()
42
+
43
+ if config_path is None:
44
+ # Look for config.json in current directory or project root
45
+ search_paths = [
46
+ Path.cwd() / "config.json",
47
+ Path(__file__).parent.parent.parent / "config.json",
48
+ ]
49
+ for path in search_paths:
50
+ if path.exists():
51
+ config_path = path
52
+ break
53
+
54
+ if config_path and Path(config_path).exists():
55
+ print(f"Loading config from: {config_path}")
56
+ with open(config_path, "r") as f:
57
+ loaded_config = json.load(f)
58
+ # Deep merge for nested dicts like 'cache'
59
+ for key, value in loaded_config.items():
60
+ if isinstance(value, dict) and key in config:
61
+ config[key].update(value)
62
+ else:
63
+ config[key] = value
64
+ else:
65
+ print("Using default configuration (no config.json found)")
66
+
67
+ return config