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 +16 -0
- dropdrop/cache.py +133 -0
- dropdrop/cli.py +252 -0
- dropdrop/config.py +67 -0
- dropdrop/pipeline.py +400 -0
- dropdrop/stats.py +299 -0
- dropdrop/ui.py +441 -0
- dropdrop-1.1.0.dist-info/METADATA +179 -0
- dropdrop-1.1.0.dist-info/RECORD +12 -0
- dropdrop-1.1.0.dist-info/WHEEL +4 -0
- dropdrop-1.1.0.dist-info/entry_points.txt +2 -0
- dropdrop-1.1.0.dist-info/licenses/LICENSE +21 -0
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))
|