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/pipeline.py ADDED
@@ -0,0 +1,400 @@
1
+ """Main droplet and inclusion detection pipeline."""
2
+
3
+ import re
4
+ import sys
5
+ from collections import defaultdict
6
+ from pathlib import Path
7
+
8
+ import cv2
9
+ import numpy as np
10
+ import pandas as pd
11
+ from tqdm import tqdm
12
+
13
+ from .cache import CacheManager
14
+ from .config import load_config
15
+
16
+ # Required: Cellpose
17
+ try:
18
+ from cellpose.models import CellposeModel
19
+ except ImportError:
20
+ print("You need to have cellpose for this pipeline to work!")
21
+ sys.exit(1)
22
+
23
+
24
+ class DropletInclusionPipeline:
25
+ """Main pipeline for droplet and inclusion detection."""
26
+
27
+ def __init__(self, config=None, store_visualizations=False, use_cache=True):
28
+ """Initialize pipeline with configuration.
29
+
30
+ Args:
31
+ config: Configuration dict. If None, loads from config.json.
32
+ store_visualizations: Whether to store visualization data for UI.
33
+ use_cache: Whether to use caching for expensive computations.
34
+ """
35
+ self.config = config if config else load_config()
36
+ self.results_data = []
37
+ self.store_visualizations = store_visualizations
38
+ self.visualization_data = {} if store_visualizations else None
39
+ self.use_cache = use_cache
40
+ self.cache = CacheManager(self.config) if use_cache else None
41
+
42
+ def parse_filename(self, filename):
43
+ """Extract z-stack index and frame index from filename.
44
+
45
+ Files without z-index are treated as single images (z_index=0).
46
+ """
47
+ z_match = re.search(r"_z(\d+)_", filename)
48
+ z_index = int(z_match.group(1)) if z_match else 0
49
+
50
+ f_match = re.search(r"a01f(\d+)d4", filename, re.IGNORECASE)
51
+ frame_index = int(f_match.group(1)) if f_match else None
52
+
53
+ return z_index, frame_index
54
+
55
+ def load_and_group_images(self, input_dir):
56
+ """Load images and group by frame index."""
57
+ input_path = Path(input_dir)
58
+
59
+ # Find all image files
60
+ extensions = [".tif", ".tiff", ".png", ".jpg", ".jpeg"]
61
+ image_files = []
62
+ for ext in extensions:
63
+ image_files.extend(input_path.glob(f"*{ext}"))
64
+ image_files.extend(input_path.glob(f"*{ext.upper()}"))
65
+
66
+ # Group by frame
67
+ frame_groups = defaultdict(list)
68
+ for filepath in image_files:
69
+ z_idx, frame_idx = self.parse_filename(filepath.name)
70
+ if frame_idx is not None:
71
+ frame_groups[frame_idx].append((z_idx, filepath))
72
+
73
+ # Sort z-stacks within each frame
74
+ for frame_idx in frame_groups:
75
+ frame_groups[frame_idx].sort(key=lambda x: x[0])
76
+
77
+ return frame_groups
78
+
79
+ def create_min_projection(self, z_stack_files):
80
+ """Create minimum intensity projection with CLAHE preprocessing."""
81
+ images = []
82
+ for z_idx, filepath in z_stack_files:
83
+ img = cv2.imread(str(filepath), cv2.IMREAD_ANYDEPTH | cv2.IMREAD_GRAYSCALE)
84
+ if img is not None:
85
+ # Convert to 8-bit first
86
+ if img.dtype == np.uint16:
87
+ img = img.astype(np.float32) * 64
88
+ img = np.clip(img, 0, 65535)
89
+ img = (img / 256).astype(np.uint8)
90
+ else:
91
+ img = np.clip(img, 0, 255).astype(np.uint8)
92
+
93
+ images.append(img)
94
+
95
+ if not images:
96
+ return None
97
+
98
+ # Create min projection
99
+ stack = np.stack(images, axis=0)
100
+ min_proj = np.min(stack, axis=0).astype(np.uint8)
101
+
102
+ # Apply CLAHE to normalize local contrast
103
+ clahe = cv2.createCLAHE(clipLimit=2.0, tileGridSize=(8, 8))
104
+ min_proj = clahe.apply(min_proj)
105
+
106
+ return min_proj
107
+
108
+ def detect_droplets_cellpose(self, image):
109
+ """Detect droplets using Cellpose."""
110
+ model = CellposeModel(gpu=True)
111
+
112
+ masks, flows, styles = model.eval(
113
+ image,
114
+ normalize=True,
115
+ flow_threshold=self.config["cellpose_flow_threshold"],
116
+ cellprob_threshold=self.config["cellpose_cellprob_threshold"],
117
+ )
118
+
119
+ return self.masks_to_coordinates(masks)
120
+
121
+ def masks_to_coordinates(self, masks):
122
+ """Convert Cellpose masks to coordinate format."""
123
+ coordinate_list = []
124
+
125
+ # Get unique mask IDs (excluding 0 for background)
126
+ unique_ids = np.unique(masks)[1:]
127
+
128
+ for mask_id in unique_ids:
129
+ binary_mask = (masks == mask_id).astype(np.uint8)
130
+ coords = self.mask_to_coordinates(binary_mask)
131
+ if coords is not None:
132
+ coordinate_list.append(coords)
133
+
134
+ return coordinate_list
135
+
136
+ def mask_to_coordinates(self, binary_mask):
137
+ """Convert single binary mask to coordinates."""
138
+ contours, _ = cv2.findContours(
139
+ binary_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
140
+ )
141
+
142
+ if not contours:
143
+ return None
144
+
145
+ contour = max(contours, key=cv2.contourArea)
146
+ coords = []
147
+ for point in contour:
148
+ coords.extend([str(point[0][0]), str(point[0][1])])
149
+
150
+ return ",".join(coords)
151
+
152
+ def coordinates_to_mask(self, coord_string, image_shape):
153
+ """Convert coordinate string back to binary mask."""
154
+ coords = [float(x) for x in coord_string.split(",")]
155
+ points = np.array(coords).reshape(-1, 2).astype(np.int32)
156
+
157
+ mask = np.zeros(image_shape, dtype=np.uint8)
158
+ cv2.fillPoly(mask, [points], 255)
159
+
160
+ return mask
161
+
162
+ def erode_mask(self, mask, erosion_pixels):
163
+ """Erode mask by specified number of pixels."""
164
+ if erosion_pixels <= 0:
165
+ return mask
166
+
167
+ kernel_size = 2 * erosion_pixels + 1
168
+ kernel = cv2.getStructuringElement(
169
+ cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)
170
+ )
171
+
172
+ return cv2.erode(mask, kernel, iterations=1)
173
+
174
+ def detect_inclusions_in_droplet(self, image, droplet_mask, store_masked=False):
175
+ """Detect inclusions within a single droplet using black-hat morphology."""
176
+ masked_image = cv2.bitwise_and(image, image, mask=droplet_mask)
177
+
178
+ kernel_size = self.config["kernel_size"]
179
+ if kernel_size % 2 == 0:
180
+ kernel_size += 1
181
+
182
+ kernel = cv2.getStructuringElement(
183
+ cv2.MORPH_ELLIPSE, (kernel_size, kernel_size)
184
+ )
185
+
186
+ blackhat = cv2.morphologyEx(masked_image, cv2.MORPH_BLACKHAT, kernel)
187
+
188
+ _, inclusions = cv2.threshold(
189
+ blackhat, self.config["tophat_threshold"], 255, cv2.THRESH_BINARY
190
+ )
191
+
192
+ inclusions = cv2.bitwise_and(inclusions, inclusions, mask=droplet_mask)
193
+ filtered_inclusions, count = self.filter_inclusions_by_size(inclusions)
194
+
195
+ if store_masked:
196
+ return filtered_inclusions, count, blackhat
197
+ return filtered_inclusions, count
198
+
199
+ def filter_inclusions_by_size(self, inclusion_mask):
200
+ """Filter detected inclusions by size constraints and edge proximity."""
201
+ num_labels, labels, stats, centroids = cv2.connectedComponentsWithStats(
202
+ inclusion_mask, connectivity=8
203
+ )
204
+
205
+ h, w = inclusion_mask.shape
206
+ edge_buffer = self.config.get("edge_buffer", 5)
207
+
208
+ filtered_mask = np.zeros_like(inclusion_mask)
209
+ inclusion_count = 0
210
+
211
+ for label in range(1, num_labels):
212
+ area = stats[label, cv2.CC_STAT_AREA]
213
+ x = stats[label, cv2.CC_STAT_LEFT]
214
+ y = stats[label, cv2.CC_STAT_TOP]
215
+ w_comp = stats[label, cv2.CC_STAT_WIDTH]
216
+ h_comp = stats[label, cv2.CC_STAT_HEIGHT]
217
+
218
+ # Edge check
219
+ if (
220
+ x < edge_buffer
221
+ or y < edge_buffer
222
+ or x + w_comp > w - edge_buffer
223
+ or y + h_comp > h - edge_buffer
224
+ ):
225
+ continue
226
+
227
+ # Size check
228
+ if (
229
+ self.config["min_inclusion_area"]
230
+ <= area
231
+ <= self.config["max_inclusion_area"]
232
+ ):
233
+ filtered_mask[labels == label] = 255
234
+ inclusion_count += 1
235
+
236
+ return filtered_mask, inclusion_count
237
+
238
+ def process_frame(self, frame_idx, min_projection, droplet_coords=None):
239
+ """Process a single frame for droplets and inclusions."""
240
+ if self.store_visualizations:
241
+ frame_viz = {
242
+ "min_projection": min_projection,
243
+ "droplet_masks": [],
244
+ "eroded_masks": [],
245
+ "inclusion_masks": [],
246
+ "masked_images": [],
247
+ }
248
+
249
+ if droplet_coords is None:
250
+ droplet_coords = self.detect_droplets_cellpose(min_projection)
251
+
252
+ if not droplet_coords:
253
+ print(f" Frame {frame_idx}: No droplets detected")
254
+ if self.store_visualizations:
255
+ self.visualization_data[frame_idx] = frame_viz
256
+ return
257
+
258
+ valid_droplet_idx = 0
259
+ for coords in droplet_coords:
260
+ droplet_mask = self.coordinates_to_mask(coords, min_projection.shape)
261
+
262
+ contours, _ = cv2.findContours(
263
+ droplet_mask, cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE
264
+ )
265
+
266
+ if not contours:
267
+ continue
268
+
269
+ M = cv2.moments(contours[0])
270
+ if M["m00"] == 0:
271
+ continue
272
+
273
+ cx = int(M["m10"] / M["m00"])
274
+ cy = int(M["m01"] / M["m00"])
275
+ area = cv2.contourArea(contours[0])
276
+ diameter = np.sqrt(4 * area / np.pi)
277
+
278
+ if not (
279
+ self.config["min_droplet_diameter"]
280
+ <= diameter
281
+ <= self.config["max_droplet_diameter"]
282
+ ):
283
+ continue
284
+
285
+ eroded_mask = self.erode_mask(droplet_mask, self.config["erosion_pixels"])
286
+
287
+ if np.sum(eroded_mask) == 0:
288
+ continue
289
+
290
+ if self.store_visualizations:
291
+ inclusion_mask, inclusion_count, blackhat = (
292
+ self.detect_inclusions_in_droplet(
293
+ min_projection, eroded_mask, store_masked=True
294
+ )
295
+ )
296
+ frame_viz["masked_images"].append(blackhat)
297
+ else:
298
+ inclusion_mask, inclusion_count = self.detect_inclusions_in_droplet(
299
+ min_projection, eroded_mask
300
+ )
301
+
302
+ if self.store_visualizations:
303
+ frame_viz["droplet_masks"].append({
304
+ "mask": droplet_mask,
305
+ "center": (cx, cy),
306
+ "radius": diameter / 2,
307
+ "inclusions": inclusion_count,
308
+ })
309
+ frame_viz["eroded_masks"].append(eroded_mask)
310
+ frame_viz["inclusion_masks"].append(inclusion_mask)
311
+
312
+ self.results_data.append({
313
+ "frame": frame_idx,
314
+ "droplet_id": valid_droplet_idx,
315
+ "center_x": cx,
316
+ "center_y": cy,
317
+ "diameter_px": diameter,
318
+ "diameter_um": diameter * self.config["px_to_um"],
319
+ "area_px": area,
320
+ "area_um2": area * (self.config["px_to_um"] ** 2),
321
+ "inclusions": inclusion_count,
322
+ })
323
+
324
+ valid_droplet_idx += 1
325
+
326
+ if self.store_visualizations:
327
+ self.visualization_data[frame_idx] = frame_viz
328
+
329
+ frame_data = [d for d in self.results_data if d["frame"] == frame_idx]
330
+ total_inclusions = sum(d["inclusions"] for d in frame_data)
331
+ print(
332
+ f" Frame {frame_idx}: {len(frame_data)} valid droplets, "
333
+ f"{total_inclusions} total inclusions"
334
+ )
335
+
336
+ def run(self, input_dir, output_dir, frame_limit=None):
337
+ """Run the complete pipeline."""
338
+ output_path = Path(output_dir)
339
+ output_path.mkdir(parents=True, exist_ok=True)
340
+
341
+ print("\nLoading and grouping images...")
342
+ frame_groups = self.load_and_group_images(input_dir)
343
+
344
+ if not frame_groups:
345
+ print("ERROR: No valid images found!")
346
+ return None
347
+
348
+ frame_indices = sorted(frame_groups.keys())
349
+ if frame_limit and frame_limit > 0:
350
+ frame_indices = frame_indices[:frame_limit]
351
+ print(f"Processing limited to first {frame_limit} frames")
352
+
353
+ print(
354
+ f"Found {len(frame_groups)} frames total, processing {len(frame_indices)} frames\n"
355
+ )
356
+
357
+ cache_hits = 0
358
+ for frame_idx in tqdm(frame_indices, desc="Processing frames"):
359
+ z_stack_files = frame_groups[frame_idx]
360
+ cache_key_file = z_stack_files[0][1].name if z_stack_files else None
361
+
362
+ if self.cache and cache_key_file and self.cache.is_valid(cache_key_file):
363
+ cached_data = self.cache.load_frame(cache_key_file)
364
+ min_proj = cached_data["min_projection"]
365
+ droplet_coords = cached_data["droplet_coords"]
366
+ cache_hits += 1
367
+ self.process_frame(frame_idx, min_proj, droplet_coords)
368
+ else:
369
+ min_proj = self.create_min_projection(z_stack_files)
370
+
371
+ if min_proj is None:
372
+ continue
373
+
374
+ droplet_coords = self.detect_droplets_cellpose(min_proj)
375
+
376
+ if self.cache and cache_key_file:
377
+ self.cache.save_frame(cache_key_file, min_proj, droplet_coords)
378
+
379
+ self.process_frame(frame_idx, min_proj, droplet_coords)
380
+
381
+ if cache_hits > 0:
382
+ print(f"\nCache: {cache_hits}/{len(frame_indices)} frames loaded from cache")
383
+
384
+ if self.results_data:
385
+ df = pd.DataFrame(self.results_data)
386
+ csv_path = output_path / "data.csv"
387
+ df.to_csv(csv_path, index=False)
388
+ print(f"\nResults saved to: {csv_path}")
389
+ self.print_summary(df)
390
+ else:
391
+ print("\nNo droplets detected in any frame!")
392
+
393
+ return self.results_data
394
+
395
+ def print_summary(self, df):
396
+ """Print one-line summary."""
397
+ print(
398
+ f"\nDetected {len(df)} droplets with {df['inclusions'].sum()} inclusions "
399
+ f"({df['inclusions'].mean():.2f} per droplet)"
400
+ )
dropdrop/stats.py ADDED
@@ -0,0 +1,299 @@
1
+ """Statistical analysis for droplet detection results."""
2
+
3
+ from datetime import datetime
4
+ from pathlib import Path
5
+
6
+ import matplotlib.pyplot as plt
7
+ import numpy as np
8
+ import pandas as pd
9
+ import seaborn as sns
10
+ from scipy import stats
11
+
12
+ # Set style for better-looking plots
13
+ sns.set_style("whitegrid")
14
+ plt.rcParams["figure.dpi"] = 100
15
+ plt.rcParams["savefig.dpi"] = 300
16
+
17
+
18
+ class DropletStatistics:
19
+ """Statistical analysis for droplet detection."""
20
+
21
+ def __init__(self, results_csv, settings=None):
22
+ """Initialize with results data and optional settings.
23
+
24
+ Args:
25
+ results_csv: Path to CSV file with detection results.
26
+ settings: Dict with 'count', 'dilution', 'poisson' keys.
27
+ """
28
+ self.df = pd.read_csv(results_csv)
29
+ self.settings = settings or {}
30
+
31
+ self.bead_count = self.settings.get("count", 6.5e5)
32
+ self.dilution = self.settings.get("dilution", 1000)
33
+ self.use_poisson = self.settings.get("poisson", True)
34
+
35
+ def calculate_poisson(self, median_diameter_um):
36
+ """Calculate theoretical Poisson distribution."""
37
+ radius_um = median_diameter_um / 2
38
+ volume_ml = (4 / 3) * np.pi * (radius_um**3) * 1e-9
39
+
40
+ lambda_val = (self.bead_count / (self.dilution * 2)) * volume_ml
41
+
42
+ max_inc = int(self.df["inclusions"].max()) + 3
43
+ x_range = np.arange(0, max_inc + 1)
44
+ theoretical = stats.poisson.pmf(x_range, lambda_val)
45
+
46
+ return x_range, theoretical, lambda_val
47
+
48
+ def plot_size_distribution(self, output_path):
49
+ """Plot droplet diameter distribution."""
50
+ fig, ax = plt.subplots(figsize=(8, 5))
51
+
52
+ diameters = self.df["diameter_um"].values
53
+ ax.hist(diameters, bins=25, color="steelblue", edgecolor="black", alpha=0.7)
54
+
55
+ mean_d = np.mean(diameters)
56
+ median_d = np.median(diameters)
57
+
58
+ ax.axvline(mean_d, color="red", linestyle="--", label=f"Mean: {mean_d:.1f}")
59
+ ax.axvline(
60
+ median_d, color="green", linestyle="--", label=f"Median: {median_d:.1f}"
61
+ )
62
+
63
+ ax.set_xlabel("Diameter (µm)")
64
+ ax.set_ylabel("Count")
65
+ ax.set_title("Droplet Size Distribution")
66
+ ax.legend()
67
+ ax.grid(True, alpha=0.3)
68
+
69
+ plt.tight_layout()
70
+ plt.savefig(output_path / "size_distribution.png", dpi=200)
71
+ plt.close()
72
+
73
+ return mean_d, median_d
74
+
75
+ def plot_poisson_comparison(self, output_path):
76
+ """Plot detected vs theoretical Poisson with chi-squared test."""
77
+ fig, ax = plt.subplots(figsize=(10, 6))
78
+
79
+ median_d = self.df["diameter_um"].median()
80
+ x_range, theoretical, lambda_val = self.calculate_poisson(median_d)
81
+
82
+ actual = self.df["inclusions"].value_counts().sort_index()
83
+ n_droplets = len(self.df)
84
+
85
+ chi2, p_value = self.perform_chi_squared(actual, theoretical, n_droplets)
86
+
87
+ detected_pct = []
88
+ theoretical_pct = theoretical * 100
89
+
90
+ for i in x_range:
91
+ if i in actual.index:
92
+ detected_pct.append(actual[i] / n_droplets * 100)
93
+ else:
94
+ detected_pct.append(0)
95
+
96
+ x = np.arange(len(x_range))
97
+ width = 0.35
98
+
99
+ ax.bar(
100
+ x - width / 2,
101
+ detected_pct,
102
+ width,
103
+ label="Detected",
104
+ color="royalblue",
105
+ alpha=0.8,
106
+ )
107
+ ax.bar(
108
+ x + width / 2,
109
+ theoretical_pct[: len(x)],
110
+ width,
111
+ label=f"Poisson (λ={lambda_val:.3f})",
112
+ color="coral",
113
+ alpha=0.8,
114
+ )
115
+
116
+ if p_value is not None:
117
+ result_text = f"χ² = {chi2:.2f}, p = {p_value:.4f}"
118
+ if p_value > 0.05:
119
+ result_text += "\n✓ Follows Poisson"
120
+ else:
121
+ result_text += "\n✗ Deviates from Poisson"
122
+ ax.text(
123
+ 0.98,
124
+ 0.85,
125
+ result_text,
126
+ transform=ax.transAxes,
127
+ ha="right",
128
+ va="top",
129
+ fontsize=10,
130
+ bbox=dict(boxstyle="round", facecolor="wheat", alpha=0.8),
131
+ )
132
+
133
+ ax.set_xlabel("Inclusions per Droplet")
134
+ ax.set_ylabel("Percentage (%)")
135
+ ax.set_title("Inclusion Distribution: Detected vs Theoretical")
136
+ ax.set_xticks(x)
137
+ ax.set_xticklabels(x_range)
138
+ ax.legend()
139
+ ax.grid(True, alpha=0.3, axis="y")
140
+
141
+ plt.tight_layout()
142
+ plt.savefig(output_path / "poisson_comparison.png", dpi=200)
143
+ plt.close()
144
+
145
+ return lambda_val, chi2, p_value
146
+
147
+ def perform_chi_squared(self, observed_counts, theoretical_probs, n_total):
148
+ """Perform chi-squared goodness-of-fit test."""
149
+ observed = []
150
+ expected = []
151
+
152
+ for i in observed_counts.index:
153
+ if i < len(theoretical_probs):
154
+ obs = observed_counts[i]
155
+ exp = theoretical_probs[i] * n_total
156
+ observed.append(obs)
157
+ expected.append(exp)
158
+
159
+ observed = np.array(observed)
160
+ expected = np.array(expected)
161
+
162
+ mask = expected >= 5
163
+ if mask.sum() < 2:
164
+ return None, None
165
+
166
+ observed_filtered = observed[mask]
167
+ expected_filtered = expected[mask]
168
+
169
+ expected_filtered = expected_filtered * (
170
+ observed_filtered.sum() / expected_filtered.sum()
171
+ )
172
+
173
+ chi2, p_value = stats.chisquare(observed_filtered, expected_filtered)
174
+ return chi2, p_value
175
+
176
+ def run_analysis(self, output_dir):
177
+ """Run analysis and print results."""
178
+ output_path = Path(output_dir)
179
+ output_path.mkdir(exist_ok=True)
180
+
181
+ mean_d, median_d = self.plot_size_distribution(output_path)
182
+
183
+ lambda_val, chi2, p_value = None, None, None
184
+ if self.use_poisson:
185
+ lambda_val, chi2, p_value = self.plot_poisson_comparison(output_path)
186
+
187
+ total_droplets = len(self.df)
188
+ total_inclusions = int(self.df["inclusions"].sum())
189
+ with_inclusions = int((self.df["inclusions"] > 0).sum())
190
+ std_d = self.df["diameter_um"].std()
191
+
192
+ self._write_summary(
193
+ output_path,
194
+ mean_d=mean_d,
195
+ median_d=median_d,
196
+ std_d=std_d,
197
+ total_droplets=total_droplets,
198
+ total_inclusions=total_inclusions,
199
+ with_inclusions=with_inclusions,
200
+ lambda_val=lambda_val,
201
+ chi2=chi2,
202
+ p_value=p_value,
203
+ )
204
+
205
+ print("\nSTATISTICAL SUMMARY")
206
+ print("-" * 40)
207
+ print(f"Droplets: {total_droplets}")
208
+ print(f"Mean diameter: {mean_d:.1f} µm")
209
+ print(
210
+ f"Inclusions: {total_inclusions} total, {total_inclusions / total_droplets:.2f} per droplet"
211
+ )
212
+ print(
213
+ f"With inclusions: {with_inclusions} ({with_inclusions / total_droplets * 100:.1f}%)"
214
+ )
215
+
216
+ if self.use_poisson and lambda_val is not None:
217
+ print(f"Theoretical λ: {lambda_val:.3f}")
218
+
219
+ if p_value is not None:
220
+ print(f"\nChi-squared test:")
221
+ print(f" χ² = {chi2:.2f}, p = {p_value:.4f}")
222
+ if p_value > 0.05:
223
+ print(" → Distribution follows Poisson (p > 0.05)")
224
+ else:
225
+ print(" → Distribution deviates from Poisson (p < 0.05)")
226
+
227
+ print(f"\nOutput saved to: {output_path}")
228
+
229
+ def _write_summary(self, output_path, **stats):
230
+ """Write summary.txt file with all settings and statistics."""
231
+ project_name = output_path.name
232
+ timestamp = datetime.now().strftime("%Y-%m-%d %H:%M:%S")
233
+ input_dir = self.settings.get("input_dir", "N/A")
234
+ total_frames = self.df["frame"].nunique()
235
+
236
+ lines = [
237
+ "=" * 80,
238
+ "DROPDROP ANALYSIS SUMMARY".center(80),
239
+ "=" * 80,
240
+ "",
241
+ f"Project: {project_name}",
242
+ f"Date: {timestamp}",
243
+ f"Input: {input_dir} ({total_frames} frames)",
244
+ "",
245
+ "SETTINGS",
246
+ "-" * 40,
247
+ f"Poisson Analysis: {'ON' if self.use_poisson else 'OFF'}",
248
+ ]
249
+
250
+ if self.use_poisson:
251
+ lines.extend([
252
+ f"Stock Concentration: {self.bead_count:.2e} beads/uL",
253
+ f"Dilution Factor: {self.dilution}x",
254
+ ])
255
+
256
+ lines.extend([
257
+ "",
258
+ "RESULTS",
259
+ "-" * 40,
260
+ f"Total Frames Processed: {total_frames}",
261
+ f"Total Droplets Detected: {stats['total_droplets']:,}",
262
+ f"Total Beads Detected: {stats['total_inclusions']:,}",
263
+ "",
264
+ "Droplet Statistics:",
265
+ f" Mean Diameter: {stats['mean_d']:.1f} um",
266
+ f" Median Diameter: {stats['median_d']:.1f} um",
267
+ f" Std Deviation: {stats['std_d']:.1f} um",
268
+ "",
269
+ "Bead Statistics:",
270
+ f" Mean per Droplet: {stats['total_inclusions'] / stats['total_droplets']:.2f}",
271
+ f" Droplets with Beads: {stats['with_inclusions']} ({stats['with_inclusions'] / stats['total_droplets'] * 100:.1f}%)",
272
+ ])
273
+
274
+ if self.use_poisson and stats.get("lambda_val") is not None:
275
+ lines.extend([
276
+ "",
277
+ "POISSON ANALYSIS",
278
+ "-" * 40,
279
+ f"Theoretical Lambda: {stats['lambda_val']:.3f}",
280
+ ])
281
+
282
+ if stats.get("p_value") is not None:
283
+ result = "FOLLOWS" if stats["p_value"] > 0.05 else "DEVIATES FROM"
284
+ lines.extend([
285
+ f"Chi-squared: {stats['chi2']:.2f}",
286
+ f"P-value: {stats['p_value']:.4f}",
287
+ f"Result: Distribution {result} Poisson (p {'>' if stats['p_value'] > 0.05 else '<'} 0.05)",
288
+ ])
289
+
290
+ lines.extend([
291
+ "",
292
+ "=" * 80,
293
+ "Generated by DropDrop",
294
+ "=" * 80,
295
+ ])
296
+
297
+ summary_path = output_path / "summary.txt"
298
+ with open(summary_path, "w") as f:
299
+ f.write("\n".join(lines))