callumtilbury commited on
Commit
495b351
·
verified ·
1 Parent(s): bd3acf0

Add dataset classes (pseudo-label + synthetic)

Browse files
Files changed (1) hide show
  1. dataset.py +292 -0
dataset.py ADDED
@@ -0,0 +1,292 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Dataset and augmentation for pseudo-label distillation training.
3
+
4
+ Loads image + pseudo-label pairs (mask, flows, distance transform)
5
+ generated by Stage 1 (generate_pseudolabels.py).
6
+
7
+ Augmentation strategy (from Cellpose training + PicoSAM2):
8
+ - Random horizontal/vertical flips
9
+ - Random rotation (0, 90, 180, 270)
10
+ - Random crop (if images are large)
11
+ - Intensity jitter (brightness, contrast)
12
+ - Gaussian noise
13
+ - Elastic deformation (circles are robust to small deformations)
14
+ """
15
+
16
+ import os
17
+ from pathlib import Path
18
+
19
+ import numpy as np
20
+ import torch
21
+ from torch.utils.data import Dataset
22
+ from scipy import ndimage
23
+ from skimage import io as skio
24
+
25
+
26
+ class BubblePseudoLabelDataset(Dataset):
27
+ """
28
+ Dataset for distillation training from Cellpose pseudo-labels.
29
+
30
+ Expects a directory with files:
31
+ {image_stem}_mask.npy — instance mask (H, W), int32
32
+ {image_stem}_flows.npy — flow fields (3, H, W), float32 [dY, dX, cell_prob]
33
+ {image_stem}_dist.npy — distance transform (H, W), float32
34
+
35
+ And the original images in a separate directory.
36
+
37
+ Args:
38
+ image_dir: Directory with original images
39
+ label_dir: Directory with pseudo-labels from Stage 1
40
+ crop_size: Random crop size (H, W). None for full images.
41
+ augment: Whether to apply data augmentation
42
+ normalize: Whether to normalize images to [0, 1]
43
+ """
44
+
45
+ def __init__(
46
+ self,
47
+ image_dir,
48
+ label_dir,
49
+ crop_size=None,
50
+ augment=True,
51
+ normalize=True,
52
+ ):
53
+ self.image_dir = Path(image_dir)
54
+ self.label_dir = Path(label_dir)
55
+ self.crop_size = crop_size
56
+ self.augment = augment
57
+ self.normalize = normalize
58
+
59
+ # Find matching image-label pairs
60
+ extensions = {".png", ".jpg", ".jpeg", ".tif", ".tiff", ".bmp"}
61
+ image_files = {f.stem: f for f in self.image_dir.iterdir() if f.suffix.lower() in extensions}
62
+ label_stems = {f.stem.replace("_mask", "") for f in self.label_dir.glob("*_mask.npy")}
63
+
64
+ self.samples = []
65
+ for stem in sorted(label_stems):
66
+ if stem in image_files:
67
+ self.samples.append({
68
+ "image_path": image_files[stem],
69
+ "mask_path": self.label_dir / f"{stem}_mask.npy",
70
+ "flows_path": self.label_dir / f"{stem}_flows.npy",
71
+ "dist_path": self.label_dir / f"{stem}_dist.npy",
72
+ })
73
+
74
+ print(f"Found {len(self.samples)} image-label pairs")
75
+
76
+ def __len__(self):
77
+ return len(self.samples)
78
+
79
+ def __getitem__(self, idx):
80
+ sample = self.samples[idx]
81
+
82
+ # Load image
83
+ img = skio.imread(str(sample["image_path"]))
84
+ if img.ndim == 3 and img.shape[2] >= 3:
85
+ img = np.mean(img[:, :, :3], axis=2) # to grayscale
86
+ img = img.astype(np.float32)
87
+
88
+ # Load pseudo-labels
89
+ mask = np.load(sample["mask_path"]).astype(np.float32)
90
+ flows = np.load(sample["flows_path"]).astype(np.float32) # (3, H, W)
91
+ dist = np.load(sample["dist_path"]).astype(np.float32)
92
+
93
+ # Normalize image
94
+ if self.normalize:
95
+ img = self._normalize_image(img)
96
+
97
+ # Normalize targets
98
+ flow_dY = flows[0]
99
+ flow_dX = flows[1]
100
+ cell_prob = flows[2]
101
+
102
+ # Normalize cell_prob to [0, 1] if not already
103
+ if cell_prob.max() > 1.0:
104
+ cell_prob = (cell_prob - cell_prob.min()) / (cell_prob.max() - cell_prob.min() + 1e-8)
105
+
106
+ # Binary mask from instance mask (for cell_prob target)
107
+ binary_mask = (mask > 0).astype(np.float32)
108
+
109
+ # Normalize distance transform: divide by max to get [0, 1] range
110
+ dist_max = dist.max()
111
+ if dist_max > 0:
112
+ dist_norm = dist / dist_max
113
+ else:
114
+ dist_norm = dist
115
+
116
+ # Stack targets: (4, H, W) = [dY, dX, cell_prob_binary, dist_norm]
117
+ target = np.stack([flow_dY, flow_dX, binary_mask, dist_norm], axis=0)
118
+
119
+ # Augmentation
120
+ if self.augment:
121
+ img, target = self._augment(img, target)
122
+
123
+ # Random crop
124
+ if self.crop_size is not None:
125
+ img, target = self._random_crop(img, target, self.crop_size)
126
+
127
+ # To tensors
128
+ img_tensor = torch.from_numpy(img[np.newaxis]).float() # (1, H, W)
129
+ target_tensor = torch.from_numpy(target).float() # (4, H, W)
130
+
131
+ return img_tensor, target_tensor, dist_max # dist_max for denormalization
132
+
133
+ def _normalize_image(self, img):
134
+ """Percentile normalization (robust to outliers)."""
135
+ p1, p99 = np.percentile(img, [1, 99])
136
+ if p99 - p1 > 0:
137
+ img = (img - p1) / (p99 - p1)
138
+ else:
139
+ img = img / (img.max() + 1e-8)
140
+ return np.clip(img, 0, 1)
141
+
142
+ def _augment(self, img, target):
143
+ """Apply geometric and intensity augmentations.
144
+
145
+ Geometric augmentations are applied consistently to image and targets.
146
+ Intensity augmentations are applied only to the image.
147
+ """
148
+ # Random horizontal flip
149
+ if np.random.random() < 0.5:
150
+ img = np.flip(img, axis=1).copy()
151
+ target = np.flip(target, axis=2).copy()
152
+ target[1] = -target[1] # flip dX direction
153
+
154
+ # Random vertical flip
155
+ if np.random.random() < 0.5:
156
+ img = np.flip(img, axis=0).copy()
157
+ target = np.flip(target, axis=1).copy()
158
+ target[0] = -target[0] # flip dY direction
159
+
160
+ # Random 90-degree rotations
161
+ k = np.random.randint(4)
162
+ if k > 0:
163
+ img = np.rot90(img, k).copy()
164
+ target = np.rot90(target, k, axes=(1, 2)).copy()
165
+ # Adjust flow directions for rotation
166
+ if k == 1: # 90° CCW
167
+ target[0], target[1] = target[1].copy(), -target[0].copy()
168
+ elif k == 2: # 180°
169
+ target[0] = -target[0]
170
+ target[1] = -target[1]
171
+ elif k == 3: # 270° CCW = 90° CW
172
+ target[0], target[1] = -target[1].copy(), target[0].copy()
173
+
174
+ # Intensity augmentations (image only)
175
+ if np.random.random() < 0.5:
176
+ delta = np.random.uniform(-0.1, 0.1)
177
+ img = np.clip(img + delta, 0, 1)
178
+
179
+ if np.random.random() < 0.5:
180
+ factor = np.random.uniform(0.8, 1.2)
181
+ mean = img.mean()
182
+ img = np.clip((img - mean) * factor + mean, 0, 1)
183
+
184
+ if np.random.random() < 0.3:
185
+ noise = np.random.normal(0, 0.02, img.shape).astype(np.float32)
186
+ img = np.clip(img + noise, 0, 1)
187
+
188
+ return img, target
189
+
190
+ def _random_crop(self, img, target, crop_size):
191
+ """Random crop of image and target."""
192
+ h, w = img.shape[-2:] if img.ndim >= 2 else img.shape
193
+ ch, cw = crop_size
194
+
195
+ if h <= ch or w <= cw:
196
+ pad_h = max(ch - h, 0)
197
+ pad_w = max(cw - w, 0)
198
+ img = np.pad(img, ((0, pad_h), (0, pad_w)), mode="reflect")
199
+ target = np.pad(target, ((0, 0), (0, pad_h), (0, pad_w)), mode="reflect")
200
+ h, w = img.shape[-2:] if img.ndim >= 2 else img.shape
201
+
202
+ y = np.random.randint(0, h - ch + 1)
203
+ x = np.random.randint(0, w - cw + 1)
204
+
205
+ img = img[y : y + ch, x : x + cw]
206
+ target = target[:, y : y + ch, x : x + cw]
207
+
208
+ return img, target
209
+
210
+
211
+ class SyntheticBubbleDataset(Dataset):
212
+ """
213
+ Synthetic dataset for testing/debugging the training pipeline.
214
+ Generates random circle images with corresponding labels.
215
+
216
+ Useful for verifying the model trains correctly before using real data.
217
+ """
218
+
219
+ def __init__(self, n_samples=100, image_size=256, n_bubbles_range=(5, 30),
220
+ radius_range=(5, 25), noise_std=0.05):
221
+ self.n_samples = n_samples
222
+ self.image_size = image_size
223
+ self.n_bubbles_range = n_bubbles_range
224
+ self.radius_range = radius_range
225
+ self.noise_std = noise_std
226
+
227
+ def __len__(self):
228
+ return self.n_samples
229
+
230
+ def __getitem__(self, idx):
231
+ H = W = self.image_size
232
+ img = np.random.uniform(0.1, 0.3, (H, W)).astype(np.float32)
233
+
234
+ mask = np.zeros((H, W), dtype=np.int32)
235
+ n_bubbles = np.random.randint(*self.n_bubbles_range)
236
+
237
+ yy, xx = np.mgrid[:H, :W]
238
+
239
+ for i in range(1, n_bubbles + 1):
240
+ cx = np.random.randint(20, W - 20)
241
+ cy = np.random.randint(20, H - 20)
242
+ r = np.random.randint(*self.radius_range)
243
+
244
+ dist = np.sqrt((yy - cy) ** 2 + (xx - cx) ** 2)
245
+ circle = dist <= r
246
+
247
+ overlap = (mask > 0) & circle
248
+ if overlap.sum() > 0.3 * circle.sum():
249
+ continue
250
+
251
+ mask[circle] = i
252
+
253
+ ring = (dist >= r - 2) & (dist <= r + 1)
254
+ img[circle] += np.random.uniform(0.3, 0.5)
255
+ img[ring] += np.random.uniform(0.1, 0.2)
256
+
257
+ img += np.random.normal(0, self.noise_std, img.shape).astype(np.float32)
258
+ img = np.clip(img, 0, 1)
259
+
260
+ # Compute targets
261
+ flow_dY = np.zeros((H, W), dtype=np.float32)
262
+ flow_dX = np.zeros((H, W), dtype=np.float32)
263
+ for label_id in range(1, mask.max() + 1):
264
+ instance = (mask == label_id)
265
+ if instance.sum() == 0:
266
+ continue
267
+ cy_m, cx_m = ndimage.center_of_mass(instance)
268
+ flow_dY[instance] = cy_m - yy[instance]
269
+ flow_dX[instance] = cx_m - xx[instance]
270
+
271
+ flow_max = max(np.abs(flow_dY).max(), np.abs(flow_dX).max(), 1e-8)
272
+ flow_dY /= flow_max
273
+ flow_dX /= flow_max
274
+
275
+ binary_mask = (mask > 0).astype(np.float32)
276
+
277
+ dist_transform = np.zeros((H, W), dtype=np.float32)
278
+ for label_id in range(1, mask.max() + 1):
279
+ instance = (mask == label_id)
280
+ if instance.sum() == 0:
281
+ continue
282
+ d = ndimage.distance_transform_edt(instance)
283
+ dist_transform[instance] = d[instance]
284
+ dist_max = dist_transform.max() if dist_transform.max() > 0 else 1.0
285
+ dist_norm = dist_transform / dist_max
286
+
287
+ target = np.stack([flow_dY, flow_dX, binary_mask, dist_norm], axis=0)
288
+
289
+ img_tensor = torch.from_numpy(img[np.newaxis]).float()
290
+ target_tensor = torch.from_numpy(target).float()
291
+
292
+ return img_tensor, target_tensor, dist_max