Skip to content

Commit c2217b2

Browse files
authored
Add random percentile normalization augmentation (#1299)
* Add random percentile normalization augmentation * Round sampled percentiles to one decimal place * Shim down percentile upper and lower bound ranges * Move percentile normalization transform to torch-em * Switch validation normalization to 2-98
1 parent cc60cc0 commit c2217b2

3 files changed

Lines changed: 205 additions & 14 deletions

File tree

micro_sam/v2/datasets/generalist_loader.py

Lines changed: 52 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -17,14 +17,14 @@
1717
from .sampler import UniBatchSampler, _build_group_map
1818
from ..transforms.raw import (
1919
_identity, _cellpose_raw_trafo, _to_8bit, _normalize_percentile, _resize_raw_to_512, _resize_to_512,
20+
get_random_percentile_normalization,
2021
)
2122
from ..transforms.labels import (
2223
_em_cell_label_trafo, _joint_em_cell_label_trafo,
2324
_plantseg_label_trafo, _axondeepseg_pre_label_transform, _instance_labels,
2425
_JointLabelTransform,
2526
)
2627

27-
2828
# Cap on validation samples drawn per dataset, to keep the per-epoch validation pass cheap.
2929
# Each access is a random crop (see UniDataWrapper.max_samples), so this is N random samples.
3030
N_SAMPLES_VAL = 50
@@ -34,6 +34,11 @@
3434
# Sam2Trainer._validate_impl, so the validation metric is comparable across epochs.
3535
VALIDATION_SEED = 42
3636

37+
# Train with uniformly sampled symmetric percentiles; validate deterministically with 2nd/98th percentiles
38+
# to match the inference-time normalization in normalize_raw.
39+
TRAIN_LOWER_PERCENTILE_BOUNDS = (0.0, 5.0)
40+
VALIDATION_LOWER_PERCENTILE_BOUNDS = (2.0, 2.0)
41+
3742

3843
def seed_worker(worker_id):
3944
"""DataLoader worker_init_fn that pins per-worker RNG for deterministic validation crops.
@@ -54,6 +59,41 @@ def _ensure_native_byte_order(y):
5459
return y.byteswap().view(y.dtype.newbyteorder()) if not y.dtype.isnative else y
5560

5661

62+
def _set_percentile_normalization(dataset, lower_percentile_bounds):
63+
"""Replace fixed normalization in all torch-em leaves of a dataset tree."""
64+
if isinstance(dataset, (list, tuple)):
65+
for ds in dataset:
66+
_set_percentile_normalization(ds, lower_percentile_bounds)
67+
return
68+
69+
if isinstance(dataset, UniDataWrapper):
70+
_set_percentile_normalization(dataset.ds, lower_percentile_bounds)
71+
return
72+
73+
children = getattr(dataset, "datasets", None)
74+
if children is not None:
75+
for ds in children:
76+
_set_percentile_normalization(ds, lower_percentile_bounds)
77+
return
78+
79+
if not hasattr(dataset, "raw_transform"):
80+
raise TypeError(f"Cannot configure raw normalization for dataset of type {type(dataset).__name__}.")
81+
82+
dataset.raw_transform = get_random_percentile_normalization(
83+
dataset.raw_transform, lower_percentile_bounds=lower_percentile_bounds
84+
)
85+
86+
87+
def _configure_training_normalization(train_datasets, val_datasets):
88+
"""Enable random percentile augmentation for training and deterministic 2nd/98th validation."""
89+
_set_percentile_normalization(
90+
train_datasets, lower_percentile_bounds=TRAIN_LOWER_PERCENTILE_BOUNDS,
91+
)
92+
_set_percentile_normalization(
93+
val_datasets, lower_percentile_bounds=VALIDATION_LOWER_PERCENTILE_BOUNDS,
94+
)
95+
96+
5797
def _prepare_data_loader(dataset, batch_size, shuffle, batch_size_per_group=None, num_workers=32, deterministic=False):
5898
# For deterministic validation, re-seed workers every epoch via worker_init_fn.
5999
# This requires non-persistent workers, since persistent workers run worker_init_fn only once.
@@ -163,7 +203,7 @@ def _get_embedseg_datasets(split_choice, z):
163203
"Mouse-Organoid-Cells-CBG", "Mouse-Skull-Nuclei-CBG",
164204
"Platynereis-ISH-Nuclei-CBG", "Platynereis-Nuclei-CBG",
165205
]
166-
else: # Only two datasets have the test split.
206+
else: # Only two datasets have the test split.
167207
names = ["Mouse-Skull-Nuclei-CBG", "Platynereis-ISH-Nuclei-CBG"]
168208

169209
all_embedseg_datasets = [
@@ -397,8 +437,8 @@ def _get_em_datasets(input_path, patch_shape, z_slices, kwargs, label_trafo, _em
397437
"""Get all electron microscopy (EM) datasets for generalist training.
398438
399439
Args:
400-
_em_label_trafo: EM cell label transform function to use. Defaults to
401-
:func:`_em_cell_label_trafo`. Pass :func:`_joint_em_cell_label_trafo`
440+
_em_label_trafo: EM cell label transform function to use. Defaults to
441+
:func:`_em_cell_label_trafo`. Pass :func:`_joint_em_cell_label_trafo`
402442
when building joint interactive+automatic datasets.
403443
404444
Returns:
@@ -705,6 +745,8 @@ def get_dataloaders(
705745
train_ds.extend(em_train)
706746
val_ds.extend(em_val)
707747

748+
_configure_training_normalization(train_ds, val_ds)
749+
708750
# Finally, we prepare a 'ConcatDataset' for all the available datasets.
709751
train_ds = ConcatDataset(*train_ds)
710752
val_ds = ConcatDataset(*val_ds)
@@ -739,7 +781,7 @@ def get_interactive_dataloaders(
739781
740782
Identical dataset composition to :func:`get_dataloaders` but returns raw
741783
integer instance labels (``label_dtype=torch.int64``) instead of distance
742-
transforms. Used with :class:`micro_sam.v2.training.ConvertToSam2VideoBatch`.
784+
transforms. Used with :class:`micro_sam.v2.training.ConvertToSam2VideoBatch`.
743785
744786
Args:
745787
input_path: Root path to the generalist training data.
@@ -815,6 +857,7 @@ def _build_automatic_datasets(input_path, z_slices, dataset_choice):
815857
train_ds.extend(em_train)
816858
val_ds.extend(em_val)
817859

860+
_configure_training_normalization(train_ds, val_ds)
818861
return ConcatDataset(*train_ds), ConcatDataset(*val_ds)
819862

820863

@@ -849,6 +892,8 @@ def _build_interactive_datasets(input_path, z_slices, dataset_choice):
849892
train_ds.extend(em_train)
850893
val_ds.extend(em_val)
851894

895+
_configure_training_normalization(train_ds, val_ds)
896+
852897
# Cap each validation dataset to N_SAMPLES_VAL random samples so the per-epoch
853898
# validation pass stays cheap (train datasets are left at full size).
854899
for w in val_ds:
@@ -897,6 +942,8 @@ def _build_joint_datasets(input_path, z_slices, dataset_choice):
897942
train_ds.extend(em_train)
898943
val_ds.extend(em_val)
899944

945+
_configure_training_normalization(train_ds, val_ds)
946+
900947
# Cap each validation dataset to N_SAMPLES_VAL random samples so the per-epoch
901948
# validation pass stays cheap (matches the interactive builder; train datasets are full size).
902949
for w in val_ds:

micro_sam/v2/transforms/raw.py

Lines changed: 81 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,12 @@
11
import random
2+
from functools import partial
3+
from typing import Callable, Dict, Optional, Tuple
24

35
import numpy as np
46
import torch
57
import torchvision.transforms.functional as TF
68
from torchvision.transforms import ColorJitter
9+
from torch_em.transform.raw import RandomPercentileNormalization, RawTransform
710

811
from micro_sam.v2.normalization import normalize_raw
912

@@ -29,25 +32,35 @@ def to_rgb(image):
2932
return image
3033

3134

32-
def _to_8bit(raw):
33-
"Ensures three channels for inputs and percentile-normalizes them to [0, 1]."
35+
def _prepare_to_8bit(raw):
36+
"""Prepare raw inputs for ``_to_8bit`` without changing their dynamic range."""
3437
if raw.ndim == 3 and raw.shape[0] == 1: # If the inputs have 1 channel, we triplicate it.
3538
raw = np.concatenate([raw] * 3, axis=0)
3639

37-
raw = to_rgb(raw) # Ensure all images are in 3-channels: triplicate one channel to three channels.
40+
return to_rgb(raw) # Ensure channel-first RGB without rescaling the original intensities.
41+
42+
43+
def _to_8bit(raw):
44+
"Ensures three channels for inputs and percentile-normalizes them to [0, 1]."
45+
raw = _prepare_to_8bit(raw)
3846
raw = normalize_raw(raw, axis=(1, 2))
3947
return raw
4048

4149

50+
def _prepare_identity(x):
51+
"""Prepare raw inputs for ``_identity`` without changing their dynamic range."""
52+
return to_rgb(x)
53+
54+
4255
def _identity(x):
4356
"Ensures three channels for inputs and percentile-normalizes them to [0, 1]."
44-
x = to_rgb(x)
57+
x = _prepare_identity(x)
4558
x = normalize_raw(x, axis=(1, 2))
4659
return x
4760

4861

49-
def _cellpose_raw_trafo(x):
50-
"""Transforms input images to desired format.
62+
def _prepare_cellpose_raw(x):
63+
"""Prepare CellPose inputs without changing their dynamic range.
5164
5265
NOTE: The input channel logic is arranged a bit strangely in `cyto` dataset.
5366
This function takes care of it here.
@@ -64,7 +77,12 @@ def _cellpose_raw_trafo(x):
6477
# The image is 2 channels and we sort the channels such that - 0: cell, 1: nucleus
6578
x = np.stack([g, r, np.zeros_like(b)], axis=0)
6679

67-
x = to_rgb(x) # Ensures three channels for inputs and avoids rescaling inputs.
80+
return to_rgb(x) # Ensures three channels for inputs and avoids rescaling inputs.
81+
82+
83+
def _cellpose_raw_trafo(x):
84+
"""Prepare and percentile-normalize CellPose inputs."""
85+
x = _prepare_cellpose_raw(x)
6886
x = normalize_raw(x, axis=(1, 2))
6987
return x
7088

@@ -77,8 +95,13 @@ def _resize_to_512(x, is_label=False):
7795

7896
def _resize_raw_to_512(x):
7997
"""Resize small raw volume patch to 512×512 and normalize."""
80-
x = _resize_to_512(x, is_label=False)
81-
return _identity(x)
98+
x = _prepare_resize_raw_to_512(x)
99+
return normalize_raw(x, axis=(1, 2))
100+
101+
102+
def _prepare_resize_raw_to_512(x):
103+
"""Resize a raw patch without normalizing it."""
104+
return _prepare_identity(_resize_to_512(x, is_label=False))
82105

83106

84107
class VideoAugment:
@@ -354,3 +377,52 @@ def _normalize_percentile(x, axis=None):
354377
'rgb' format of TissueNet image data for the expected axes.
355378
"""
356379
return normalize_raw(x, axis=axis)
380+
381+
382+
def get_random_percentile_normalization(
383+
raw_transform: Callable,
384+
lower_percentile_bounds: Tuple[float, float] = (0.0, 5.0),
385+
distribution: str = "uniform",
386+
distribution_kwargs: Optional[Dict[str, float]] = None,
387+
) -> RawTransform:
388+
"""Convert a generalist raw transform to random percentile normalization.
389+
390+
The data-specific shape/channel preparation is retained as ``RawTransform.augmentation1``, so the
391+
normalizer receives data in its original intensity range. An existing ``augmentation2`` is preserved.
392+
"""
393+
augmentation2 = None
394+
if isinstance(raw_transform, RawTransform):
395+
if not isinstance(raw_transform.normalizer, RandomPercentileNormalization):
396+
raise ValueError(f"Unsupported generalist raw transform: {raw_transform!r}")
397+
augmentation1, augmentation2 = raw_transform.augmentation1, raw_transform.augmentation2
398+
axis = raw_transform.normalizer.axis
399+
elif isinstance(raw_transform, RandomPercentileNormalization):
400+
augmentation1, axis = None, raw_transform.axis
401+
elif raw_transform is _identity:
402+
augmentation1, axis = _prepare_identity, (1, 2)
403+
elif raw_transform is _to_8bit:
404+
augmentation1, axis = _prepare_to_8bit, (1, 2)
405+
elif raw_transform is _cellpose_raw_trafo:
406+
augmentation1, axis = _prepare_cellpose_raw, (1, 2)
407+
elif raw_transform is _resize_raw_to_512:
408+
augmentation1, axis = _prepare_resize_raw_to_512, (1, 2)
409+
elif raw_transform is _normalize_percentile:
410+
augmentation1, axis = None, None
411+
elif isinstance(raw_transform, partial) and raw_transform.func is _normalize_percentile:
412+
if raw_transform.args:
413+
raise ValueError("Positional arguments are not supported for _normalize_percentile transforms.")
414+
unexpected_kwargs = set(raw_transform.keywords) - {"axis"}
415+
if unexpected_kwargs:
416+
raise ValueError(f"Unsupported _normalize_percentile arguments: {sorted(unexpected_kwargs)}")
417+
augmentation1, axis = None, raw_transform.keywords.get("axis")
418+
else:
419+
raise ValueError(f"Unsupported generalist raw transform: {raw_transform!r}")
420+
421+
normalizer = RandomPercentileNormalization(
422+
lower_percentile_bounds=lower_percentile_bounds,
423+
distribution=distribution,
424+
distribution_kwargs=distribution_kwargs,
425+
rounding_decimals=1,
426+
axis=axis,
427+
)
428+
return RawTransform(normalizer=normalizer, augmentation1=augmentation1, augmentation2=augmentation2)

test/test_raw_transforms.py

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
import unittest
2+
from functools import partial
3+
4+
import numpy as np
5+
6+
7+
class TestRandomPercentileNormalization(unittest.TestCase):
8+
def test_factory_preserves_data_specific_preprocessing(self):
9+
from torch_em.transform.raw import RandomPercentileNormalization, RawTransform
10+
11+
from micro_sam.v2.transforms.raw import _normalize_percentile, _to_8bit, get_random_percentile_normalization
12+
13+
raw = np.arange(16, dtype="uint16").reshape(1, 4, 4)
14+
transform = get_random_percentile_normalization(_to_8bit)
15+
self.assertIsInstance(transform, RawTransform)
16+
self.assertIsInstance(transform.normalizer, RandomPercentileNormalization)
17+
self.assertEqual(transform.normalizer.lower_percentile_bounds, (0.0, 5.0))
18+
self.assertEqual(transform.normalizer.distribution, "uniform")
19+
self.assertIsNone(transform.normalizer.distribution_kwargs)
20+
self.assertEqual(transform.normalizer.rounding_decimals, 1)
21+
self.assertEqual(transform(raw).shape, (3, 4, 4))
22+
23+
transform = get_random_percentile_normalization(
24+
partial(_normalize_percentile, axis=(1, 2)), lower_percentile_bounds=(0.0, 0.0),
25+
)
26+
self.assertEqual(transform.normalizer.axis, (1, 2))
27+
28+
def postprocessing(x):
29+
return x
30+
31+
transform.augmentation2 = postprocessing
32+
reconfigured = get_random_percentile_normalization(transform, lower_percentile_bounds=(0.0, 1.0))
33+
self.assertIs(reconfigured.augmentation1, transform.augmentation1)
34+
self.assertIs(reconfigured.augmentation2, postprocessing)
35+
36+
37+
class TestGeneralistNormalizationConfiguration(unittest.TestCase):
38+
def test_training_is_random_and_validation_is_deterministic(self):
39+
from torch_em.transform.raw import RandomPercentileNormalization, RawTransform
40+
41+
from micro_sam.v2.datasets.generalist_loader import (
42+
TRAIN_LOWER_PERCENTILE_BOUNDS, VALIDATION_LOWER_PERCENTILE_BOUNDS,
43+
_configure_training_normalization,
44+
)
45+
from micro_sam.v2.datasets.wrapper import UniDataWrapper
46+
from micro_sam.v2.transforms.raw import _identity
47+
48+
class Dataset:
49+
def __init__(self):
50+
self.raw_transform = _identity
51+
52+
train_leaf, val_leaf = Dataset(), Dataset()
53+
train_datasets = [UniDataWrapper(train_leaf)]
54+
val_datasets = [UniDataWrapper(val_leaf)]
55+
_configure_training_normalization(train_datasets, val_datasets)
56+
57+
self.assertIsInstance(train_leaf.raw_transform, RawTransform)
58+
self.assertIsInstance(train_leaf.raw_transform.normalizer, RandomPercentileNormalization)
59+
self.assertEqual(train_leaf.raw_transform.normalizer.distribution, "uniform")
60+
self.assertEqual(train_leaf.raw_transform.normalizer.lower_percentile_bounds, TRAIN_LOWER_PERCENTILE_BOUNDS)
61+
62+
self.assertIsInstance(val_leaf.raw_transform, RawTransform)
63+
self.assertIsInstance(val_leaf.raw_transform.normalizer, RandomPercentileNormalization)
64+
self.assertEqual(val_leaf.raw_transform.normalizer.distribution, "uniform")
65+
self.assertEqual(
66+
val_leaf.raw_transform.normalizer.lower_percentile_bounds, VALIDATION_LOWER_PERCENTILE_BOUNDS
67+
)
68+
self.assertEqual(val_leaf.raw_transform.normalizer.sample_percentiles(), (2.0, 98.0))
69+
70+
71+
if __name__ == "__main__":
72+
unittest.main()

0 commit comments

Comments
 (0)