Skip to content

Commit e2304cb

Browse files
authored
Restore input normalization back to min-max (#1296)
1 parent 28b2da8 commit e2304cb

5 files changed

Lines changed: 46 additions & 14 deletions

File tree

micro_sam/sam_annotator/_widgets.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2046,6 +2046,14 @@ def _validate_existing_embeddings(self, state):
20462046
"info", "Embeddings have already been precomputed. Press OK to recompute the embeddings."
20472047
)
20482048

2049+
@staticmethod
2050+
def _clear_autosegment_cache(state):
2051+
"""Discard predictions derived from the embeddings that were just replaced."""
2052+
widget = state.widgets.get("autosegment")
2053+
if widget is not None:
2054+
widget._segmenter = None
2055+
widget._segmenter_key = None
2056+
20492057
def _n_tiles(self, tile_shape, shape):
20502058
"""The number of tiles the embedding computation is split into (1 without tiling)."""
20512059
if tile_shape is None:
@@ -2173,6 +2181,7 @@ def pbar_update(update):
21732181
pbar_signals.pbar_stop.emit()
21742182

21752183
compute_image_embedding()
2184+
self._clear_autosegment_cache(state)
21762185
self._update_model(state)
21772186
# worker = compute_image_embedding()
21782187
# worker.returned.connect(self._update_model)

micro_sam/v2/normalization.py

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,9 @@
55
import numpy as np
66
from torch_em.transform.raw import normalize_percentile
77

8-
RAW_NORMALIZATION = "percentile_2_98"
8+
# The released UniSAM2 microscopy checkpoint and the legacy 2D embedding path use min-max normalization.
9+
# Persist the policy in embedding caches so percentile-normalized features are not silently reused.
10+
RAW_NORMALIZATION = "minmax_per_channel"
911

1012

1113
def normalize_raw(
@@ -60,13 +62,13 @@ def normalize_raw(
6062

6163

6264
def to_image(image: np.ndarray) -> np.ndarray:
63-
"""Map a 2D or channel-last image to percentile-normalized, channel-last uint8 RGB.
65+
"""Map a 2D or channel-last image to min-max-normalized, channel-last uint8 RGB.
6466
6567
Args:
6668
image: The input image. Either 2D or channel-last with up to three channels.
6769
6870
Returns:
6971
The channel-last uint8 RGB image, with each channel normalized independently.
7072
"""
71-
from micro_sam.util import _ensure_rgb
72-
return normalize_raw(_ensure_rgb(image), axis=(0, 1), output_dtype="uint8")
73+
from micro_sam.util import _to_image
74+
return _to_image(image)

micro_sam/v2/util.py

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -294,11 +294,12 @@ def _check_saved_embeddings(input_, predictor, f, save_path, tile_shape, halo):
294294
data (data signature mismatch).
295295
"""
296296
# We may have an empty zarr file that was already created to save the embeddings in. A
297-
# feature-bearing file without the completion metadata is a partial cache: only resume it when
298-
# it already records the current normalization policy. Otherwise it may contain legacy min-max
299-
# features, which must not be mixed with newly computed percentile-normalized features.
297+
# feature-bearing file without the completion metadata is a partial cache: only reject it when
298+
# it explicitly records a different normalization policy. Caches without this metadata predate
299+
# percentile normalization and therefore use the restored min-max policy.
300300
if "input_size" not in f.attrs:
301-
return "features" in f and f.attrs.get("normalization") != RAW_NORMALIZATION
301+
normalization = f.attrs.get("normalization")
302+
return "features" in f and normalization is not None and normalization != RAW_NORMALIZATION
302303

303304
# Creates all the metadta that is stored along with the embeddings.
304305
# TODO: This is currently paired with `micro_sam`-level metadata. Should we get separate for `micro_sam.v2`?
@@ -308,9 +309,9 @@ def _check_saved_embeddings(input_, predictor, f, save_path, tile_shape, halo):
308309

309310
stale = False
310311
for key, val in signature.items():
311-
# Embeddings without normalization metadata used the former min-max policy.
312+
# Embeddings without normalization metadata predate percentile normalization and used the
313+
# same min-max policy as the released checkpoints, so they remain compatible.
312314
if key not in f.attrs:
313-
stale = stale or key == "normalization"
314315
continue
315316
if f.attrs[key] == val:
316317
continue

test/test_sam_annotator/test_annotator.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -512,3 +512,14 @@ def _add_flow_integration_params(self, settings, n_iter, dt=0.5, sigma=1.0):
512512
assert autoseg.sigma == defaults["sigma"]
513513
assert autoseg.n_iter == defaults["n_iter"]
514514
assert autoseg.dt == defaults["dt"]
515+
516+
def test_embedding_recompute_clears_cached_prediction(self):
517+
from types import SimpleNamespace
518+
519+
from micro_sam.sam_annotator._widgets import EmbeddingWidget
520+
521+
autosegment = SimpleNamespace(_segmenter=object(), _segmenter_key=object())
522+
state = SimpleNamespace(widgets={"autosegment": autosegment})
523+
EmbeddingWidget._clear_autosegment_cache(state)
524+
assert autosegment._segmenter is None
525+
assert autosegment._segmenter_key is None

test/test_util.py

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -177,6 +177,7 @@ def test_normalize_raw_percentile_params(self):
177177

178178
def test_normalize_raw_per_channel(self):
179179
from micro_sam.v2.normalization import normalize_raw, to_image
180+
from micro_sam.util import _to_image
180181

181182
channel = np.arange(100, dtype="float32").reshape(10, 10)
182183
image = np.stack([channel, channel * 100 + 42], axis=-1)
@@ -188,8 +189,9 @@ def test_normalize_raw_per_channel(self):
188189
self.assertEqual(rgb.dtype, np.uint8)
189190
self.assertTrue(np.array_equal(rgb[..., 0], rgb[..., 1]))
190191
self.assertFalse(np.any(rgb[..., 2]))
192+
self.assertTrue(np.array_equal(rgb, _to_image(image)))
191193

192-
def test_normalization_invalidates_old_embeddings(self):
194+
def test_normalization_invalidates_incompatible_embeddings(self):
193195
from types import SimpleNamespace
194196

195197
from micro_sam.util import _get_embedding_signature
@@ -200,22 +202,29 @@ def test_normalization_invalidates_old_embeddings(self):
200202
raw = np.arange(100).reshape(10, 10)
201203
signature = _get_embedding_signature(raw, predictor, tile_shape=None, halo=None)
202204
signature["normalization"] = RAW_NORMALIZATION
203-
self.assertEqual(signature["normalization"], "percentile_2_98")
205+
self.assertEqual(signature["normalization"], "minmax_per_channel")
204206

205207
attrs = {"input_size": [10, 10], **signature}
206208
embeddings = SimpleNamespace(attrs=attrs)
207209
self.assertFalse(_check_saved_embeddings(raw, predictor, embeddings, "cache.zarr", None, None))
208210

209-
del attrs["normalization"]
211+
attrs["normalization"] = "percentile_2_98"
210212
self.assertTrue(_check_saved_embeddings(raw, predictor, embeddings, "cache.zarr", None, None))
211213

214+
# Untagged caches predate percentile normalization and used the same min-max policy.
215+
del attrs["normalization"]
216+
self.assertFalse(_check_saved_embeddings(raw, predictor, embeddings, "cache.zarr", None, None))
217+
212218
class PartialEmbeddings(dict):
213219
def __init__(self, normalization=None):
214220
super().__init__(features=object())
215221
self.attrs = {} if normalization is None else {"normalization": normalization}
216222

217223
legacy_partial = PartialEmbeddings()
218-
self.assertTrue(_check_saved_embeddings(raw, predictor, legacy_partial, "cache.zarr", None, None))
224+
self.assertFalse(_check_saved_embeddings(raw, predictor, legacy_partial, "cache.zarr", None, None))
225+
226+
percentile_partial = PartialEmbeddings("percentile_2_98")
227+
self.assertTrue(_check_saved_embeddings(raw, predictor, percentile_partial, "cache.zarr", None, None))
219228

220229
current_partial = PartialEmbeddings(RAW_NORMALIZATION)
221230
self.assertFalse(_check_saved_embeddings(raw, predictor, current_partial, "cache.zarr", None, None))

0 commit comments

Comments
 (0)