Skip to content

Commit a93ca45

Browse files
author
Donglai Wei
committed
bug fix: add masking during training
1 parent fc3b5b9 commit a93ca45

6 files changed

Lines changed: 150 additions & 26 deletions

File tree

connectomics/config/hydra_config.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -480,6 +480,9 @@ class DataConfig:
480480
cached_sampling_foreground_threshold: float = (
481481
0.0 # Minimum (label > 0) fraction required for training crops; 0 disables foreground sampling
482482
)
483+
cached_sampling_crop_to_nonzero_mask: bool = (
484+
False # Restrict random crops to intersect the nonzero bounding box of the mask volume
485+
)
483486

484487
# Reject sampling configuration (for volumetric patch sampling)
485488
reject_sampling: Optional[Dict[str, Any]] = None # Dict with 'size_thres' and 'p' keys

connectomics/data/dataset/dataset_volume_cached.py

Lines changed: 55 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,7 @@ def __init__(
165165
pad_mode: str = "reflect",
166166
max_attempts: int = 10,
167167
foreground_threshold: float = 0.05,
168+
crop_to_nonzero_mask: bool = False,
168169
):
169170
self.image_paths = image_paths
170171
self.label_paths = label_paths if label_paths else [None] * len(image_paths)
@@ -200,6 +201,7 @@ def __init__(
200201
self._d2_last_report_step = 0
201202
self.max_attempts = max_attempts
202203
self.foreground_threshold = foreground_threshold
204+
self.crop_to_nonzero_mask = crop_to_nonzero_mask
203205

204206
# Load all volumes into memory
205207
print(f" Loading {len(image_paths)} volumes into memory...")
@@ -293,6 +295,17 @@ def __init__(
293295
ndim = len(self.patch_size)
294296
self.volume_sizes = [img.shape[-ndim:] for img in self.cached_images] # (Z, Y, X) or (Y, X)
295297

298+
# Precompute mask bounding boxes for crop_to_nonzero_mask sampling
299+
self.mask_bboxes: List[Optional[List[Tuple[int, int]]]] = []
300+
if self.crop_to_nonzero_mask:
301+
for mask in self.cached_masks:
302+
self.mask_bboxes.append(self._compute_mask_bbox(mask))
303+
print(
304+
f" [crop_to_nonzero_mask] Mask bboxes computed for {len(self.mask_bboxes)} volumes"
305+
)
306+
else:
307+
self.mask_bboxes = [None] * len(self.cached_images)
308+
296309
# [D2 DIAGNOSTIC] Print foreground sampling configuration
297310
if self.mode == "train":
298311
if self.foreground_threshold > 0:
@@ -459,10 +472,39 @@ def get_sampling_fingerprint(self, num_samples: int = 5) -> str:
459472
# Restore RNG state
460473
random.setstate(state)
461474

475+
def _compute_mask_bbox(
476+
self, mask: Optional[np.ndarray]
477+
) -> Optional[List[Tuple[int, int]]]:
478+
"""Compute the axis-aligned bounding box of nonzero voxels in the mask.
479+
480+
Args:
481+
mask: Mask array with shape (C, D, H, W) or (C, H, W). May be None.
482+
483+
Returns:
484+
List of (start, end) pairs for each spatial dimension, or None if
485+
the mask is None or entirely zero.
486+
"""
487+
if mask is None:
488+
return None
489+
spatial = mask[0] > 0 # drop channel dim: (D, H, W) or (H, W)
490+
if not spatial.any():
491+
return None
492+
ndim = spatial.ndim
493+
bbox = []
494+
for d in range(ndim):
495+
axes = tuple(i for i in range(ndim) if i != d)
496+
proj = spatial.any(axis=axes) if ndim > 1 else spatial
497+
indices = np.where(proj)[0]
498+
bbox.append((int(indices[0]), int(indices[-1]) + 1))
499+
return bbox
500+
462501
def _get_random_crop_position(self, vol_idx: int) -> Tuple[int, ...]:
463502
"""
464503
Get a random crop position for training (like v1 VolumeDataset).
465504
505+
When crop_to_nonzero_mask is True, restricts sampling so every crop
506+
intersects the nonzero bounding box of the mask.
507+
466508
Args:
467509
vol_idx: Volume index
468510
@@ -471,18 +513,22 @@ def _get_random_crop_position(self, vol_idx: int) -> Tuple[int, ...]:
471513
"""
472514
vol_size = self.volume_sizes[vol_idx]
473515
patch_size = self.patch_size
516+
mask_bbox = self.mask_bboxes[vol_idx] if self.crop_to_nonzero_mask else None
474517

475-
# Random position ensuring crop fits within volume
476-
# Support both 2D and 3D
477-
# Ensure max_start ensures we can crop at least patch_size
478518
positions = []
479519
for i in range(len(patch_size)):
480-
max_start = max(0, vol_size[i] - patch_size[i])
481-
# Safety check: if volume is smaller than patch_size, start at 0
482-
# (crop_volume will handle padding)
483-
if max_start < 0:
484-
max_start = 0
485-
positions.append(random.randint(0, max_start))
520+
vol_max = max(0, vol_size[i] - patch_size[i])
521+
if mask_bbox is not None:
522+
# Intersection condition: crop [pos, pos+patch) overlaps [bbox_start, bbox_end)
523+
# => pos < bbox_end and pos + patch > bbox_start
524+
# => pos >= bbox_start - patch + 1 and pos <= bbox_end - 1
525+
min_start = max(0, mask_bbox[i][0] - patch_size[i] + 1)
526+
max_start = min(vol_max, mask_bbox[i][1] - 1)
527+
if min_start > max_start: # bbox too small — fall back to full range
528+
min_start, max_start = 0, vol_max
529+
else:
530+
min_start, max_start = 0, vol_max
531+
positions.append(random.randint(min_start, max_start))
486532
return tuple(positions)
487533

488534
def _get_center_crop_position(self, vol_idx: int) -> Tuple[int, ...]:

connectomics/training/deep_supervision.py

Lines changed: 72 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,7 @@ def compute_multitask_loss(
114114
outputs: torch.Tensor,
115115
labels: torch.Tensor,
116116
stage: str = "train",
117+
mask: Optional[torch.Tensor] = None,
117118
) -> Tuple[torch.Tensor, Dict[str, float]]:
118119
"""
119120
Compute losses for multi-task learning where different output channels
@@ -181,15 +182,26 @@ def compute_multitask_loss(
181182
loss_fn = self.loss_functions[loss_idx]
182183
weight = self.loss_weights[loss_idx]
183184

184-
# [D3] Pass foreground-weighted mask to loss functions that support it
185-
# (e.g., WeightedMSELoss, WeightedMAELoss, SmoothL1Loss)
185+
# [D3] Pass foreground-weighted mask (and validity mask) to loss functions
186186
if _loss_supports_weight(loss_fn):
187187
fg_weight = 2.0
188188
loss_weight_mask = torch.ones_like(task_label)
189189
loss_weight_mask[task_label > 0] = fg_weight
190+
if mask is not None:
191+
# Zero weight for voxels outside valid mask — excluded from gradient
192+
loss_weight_mask = loss_weight_mask * (mask > 0).float()
190193
loss = loss_fn(task_output, task_label, weight=loss_weight_mask)
191194
else:
192-
loss = loss_fn(task_output, task_label)
195+
if mask is not None:
196+
# For losses without per-voxel weight support (e.g. DiceLoss):
197+
# suppress output to large-negative (sigmoid≈0) and zero label
198+
# in invalid regions so they contribute ≈0 to numerator/denominator
199+
mask_bool = (mask > 0)
200+
task_output_m = task_output.masked_fill(~mask_bool, -20.0)
201+
task_label_m = task_label * mask_bool.float()
202+
loss = loss_fn(task_output_m, task_label_m)
203+
else:
204+
loss = loss_fn(task_output, task_label)
193205

194206
# Check for NaN/Inf
195207
if self.enable_nan_detection and (torch.isnan(loss) or torch.isinf(loss)):
@@ -246,7 +258,12 @@ def compute_multitask_loss(
246258
return total_loss, loss_dict
247259

248260
def compute_loss_for_scale(
249-
self, output: torch.Tensor, target: torch.Tensor, scale_idx: int, stage: str = "train"
261+
self,
262+
output: torch.Tensor,
263+
target: torch.Tensor,
264+
scale_idx: int,
265+
stage: str = "train",
266+
mask: Optional[torch.Tensor] = None,
250267
) -> Tuple[torch.Tensor, Dict[str, float]]:
251268
"""
252269
Compute loss for a single scale with multi-task or standard loss.
@@ -288,7 +305,20 @@ def compute_loss_for_scale(
288305
loss_fn = self.loss_functions[loss_idx]
289306
weight = self.loss_weights[loss_idx]
290307

291-
loss = loss_fn(task_output, task_target)
308+
if mask is not None and _loss_supports_weight(loss_fn):
309+
fg_weight = 2.0
310+
loss_weight_mask = torch.ones_like(task_target)
311+
loss_weight_mask[task_target > 0] = fg_weight
312+
loss_weight_mask = loss_weight_mask * (mask > 0).float()
313+
loss = loss_fn(task_output, task_target, weight=loss_weight_mask)
314+
elif mask is not None:
315+
mask_bool = (mask > 0)
316+
loss = loss_fn(
317+
task_output.masked_fill(~mask_bool, -20.0),
318+
task_target * mask_bool.float(),
319+
)
320+
else:
321+
loss = loss_fn(task_output, task_target)
292322

293323
# Check for NaN/Inf (only in training mode)
294324
if (
@@ -376,7 +406,11 @@ def compute_loss_for_scale(
376406
return scale_loss, loss_dict
377407

378408
def compute_deep_supervision_loss(
379-
self, outputs: Dict[str, torch.Tensor], labels: torch.Tensor, stage: str = "train"
409+
self,
410+
outputs: Dict[str, torch.Tensor],
411+
labels: torch.Tensor,
412+
stage: str = "train",
413+
mask: Optional[torch.Tensor] = None,
380414
) -> Tuple[torch.Tensor, Dict[str, float]]:
381415
"""
382416
Compute multi-scale loss with deep supervision.
@@ -385,6 +419,7 @@ def compute_deep_supervision_loss(
385419
outputs: Dictionary with 'output' and 'ds_i' keys for deep supervision
386420
labels: Ground truth labels
387421
stage: 'train' or 'val' for logging prefix
422+
mask: Optional binary validity mask (B, 1, D, H, W); loss is zeroed outside mask
388423
389424
Returns:
390425
Tuple of (total_loss, loss_dict)
@@ -419,9 +454,19 @@ def compute_deep_supervision_loss(
419454
# Match target to output size
420455
target = match_target_to_output(labels, output)
421456

457+
# Downsample mask to match this scale using nearest interpolation
458+
scale_mask = None
459+
if mask is not None:
460+
if mask.shape[2:] != output.shape[2:]:
461+
scale_mask = F.interpolate(
462+
mask.float(), size=output.shape[2:], mode="nearest"
463+
)
464+
else:
465+
scale_mask = mask
466+
422467
# Compute loss for this scale
423468
scale_loss, scale_loss_dict = self.compute_loss_for_scale(
424-
output, target, scale_idx, stage
469+
output, target, scale_idx, stage, mask=scale_mask
425470
)
426471

427472
# Accumulate with deep supervision weight
@@ -432,7 +477,11 @@ def compute_deep_supervision_loss(
432477
return total_loss, loss_dict
433478

434479
def compute_standard_loss(
435-
self, outputs: torch.Tensor, labels: torch.Tensor, stage: str = "train"
480+
self,
481+
outputs: torch.Tensor,
482+
labels: torch.Tensor,
483+
stage: str = "train",
484+
mask: Optional[torch.Tensor] = None,
436485
) -> Tuple[torch.Tensor, Dict[str, float]]:
437486
"""
438487
Compute standard single-scale loss.
@@ -441,6 +490,7 @@ def compute_standard_loss(
441490
outputs: Model outputs (B, C, D, H, W)
442491
labels: Ground truth labels (B, C, D, H, W)
443492
stage: 'train' or 'val' for logging prefix
493+
mask: Optional binary validity mask (B, 1, D, H, W); loss is zeroed outside mask
444494
445495
Returns:
446496
Tuple of (total_loss, loss_dict)
@@ -451,18 +501,29 @@ def compute_standard_loss(
451501
# Check if multi-task learning is configured
452502
if self.is_multi_task:
453503
# Multi-task learning: apply specific losses to specific channels
454-
total_loss, loss_dict = self.compute_multitask_loss(outputs, labels, stage=stage)
504+
total_loss, loss_dict = self.compute_multitask_loss(
505+
outputs, labels, stage=stage, mask=mask
506+
)
455507
else:
456508
# Standard single-scale loss: apply all losses to all outputs
457509
for i, (loss_fn, weight) in enumerate(zip(self.loss_functions, self.loss_weights)):
458-
# [D3] Pass foreground-weighted mask to loss functions that support it
510+
# [D3] Pass foreground-weighted mask (and validity mask) to loss functions
459511
if _loss_supports_weight(loss_fn):
460512
fg_weight = 2.0
461513
loss_weight_mask = torch.ones_like(labels)
462514
loss_weight_mask[labels > 0] = fg_weight
515+
if mask is not None:
516+
loss_weight_mask = loss_weight_mask * (mask > 0).float()
463517
loss = loss_fn(outputs, labels, weight=loss_weight_mask)
464518
else:
465-
loss = loss_fn(outputs, labels)
519+
if mask is not None:
520+
mask_bool = (mask > 0)
521+
loss = loss_fn(
522+
outputs.masked_fill(~mask_bool, -20.0),
523+
labels * mask_bool.float(),
524+
)
525+
else:
526+
loss = loss_fn(outputs, labels)
466527

467528
# Check for NaN/Inf (only in training mode)
468529
if (

connectomics/training/lit/data_factory.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -626,6 +626,7 @@ def _build_preloaded_nnunet_preprocess(split: str):
626626
pad_mode=pad_mode,
627627
max_attempts=cfg.data.cached_sampling_max_attempts,
628628
foreground_threshold=cfg.data.cached_sampling_foreground_threshold,
629+
crop_to_nonzero_mask=cfg.data.cached_sampling_crop_to_nonzero_mask,
629630
)
630631

631632
preloaded_num_workers = num_workers
@@ -691,6 +692,7 @@ def _build_preloaded_nnunet_preprocess(split: str):
691692
pad_mode=pad_mode,
692693
max_attempts=cfg.data.cached_sampling_max_attempts,
693694
foreground_threshold=cfg.data.cached_sampling_foreground_threshold,
695+
crop_to_nonzero_mask=cfg.data.cached_sampling_crop_to_nonzero_mask,
694696
)
695697
else:
696698
from ...data.dataset import create_volume_dataset

connectomics/training/lit/model.py

Lines changed: 14 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -590,16 +590,22 @@ def _compute_test_metrics(self, decoded_predictions: np.ndarray, labels: torch.T
590590
def training_step(self, batch: Dict[str, torch.Tensor], batch_idx: int) -> STEP_OUTPUT:
591591
"""Training step with deep supervision support."""
592592
from ...utils.debug_utils import print_tensor_stats
593-
593+
594594
images = batch['image']
595595
labels = batch['label']
596+
raw_mask = batch.get('mask', None)
597+
# Binarize mask: (B, 1, D, H, W) float, 1 = valid, 0 = ignore
598+
mask = (raw_mask > 0).float() if raw_mask is not None else None
599+
600+
if batch_idx == 0 and self.current_epoch == 0:
601+
import pdb; pdb.set_trace()
596602

597603
# Forward pass
598604
outputs = self(images)
599605

600606
# Check if model outputs deep supervision
601607
is_deep_supervision = isinstance(outputs, dict) and any(k.startswith('ds_') for k in outputs.keys())
602-
608+
603609
# DEBUG: Print model output (raw logits, before any activation)
604610
if batch_idx == 0:
605611
pred_for_debug = outputs['output'] if is_deep_supervision else outputs
@@ -617,11 +623,11 @@ def training_step(self, batch: Dict[str, torch.Tensor], batch_idx: int) -> STEP_
617623
# Compute loss using deep supervision handler
618624
if is_deep_supervision:
619625
total_loss, loss_dict = self.deep_supervision_handler.compute_deep_supervision_loss(
620-
outputs, labels, stage="train"
626+
outputs, labels, stage="train", mask=mask
621627
)
622628
else:
623629
total_loss, loss_dict = self.deep_supervision_handler.compute_standard_loss(
624-
outputs, labels, stage="train"
630+
outputs, labels, stage="train", mask=mask
625631
)
626632

627633
# [D1] Training diagnostics: log prediction and target statistics to detect SDT collapse
@@ -673,6 +679,8 @@ def validation_step(self, batch: Dict[str, torch.Tensor], batch_idx: int) -> STE
673679
"""Validation step with deep supervision support."""
674680
images = batch['image']
675681
labels = batch['label']
682+
raw_mask = batch.get('mask', None)
683+
mask = (raw_mask > 0).float() if raw_mask is not None else None
676684

677685
# Forward pass
678686
outputs = self(images)
@@ -683,11 +691,11 @@ def validation_step(self, batch: Dict[str, torch.Tensor], batch_idx: int) -> STE
683691
# Compute loss using deep supervision handler
684692
if is_deep_supervision:
685693
total_loss, loss_dict = self.deep_supervision_handler.compute_deep_supervision_loss(
686-
outputs, labels, stage="val"
694+
outputs, labels, stage="val", mask=mask
687695
)
688696
else:
689697
total_loss, loss_dict = self.deep_supervision_handler.compute_standard_loss(
690-
outputs, labels, stage="val"
698+
outputs, labels, stage="val", mask=mask
691699
)
692700

693701
# Compute evaluation metrics if enabled

tutorials/vesicle_xm.yaml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -72,8 +72,12 @@ data:
7272
iter_num_per_epoch: 1000
7373
cached_sampling_foreground_threshold: 0.1
7474
cached_sampling_max_attempts: 10
75+
cached_sampling_crop_to_nonzero_mask: true
7576

7677
patch_size: [32, 64, 64]
78+
image_transform:
79+
normalize: "0-1"
80+
7781
data_transform:
7882
resize: [32, 128, 128]
7983

0 commit comments

Comments
 (0)