From f173265354166547414ff922faa7b014de761481 Mon Sep 17 00:00:00 2001 From: Adil Zouitine Date: Mon, 29 Sep 2025 16:02:15 +0200 Subject: [PATCH] feat(normalization): add validation for empty features in NormalizerProcessorStep and UnnormalizerProcessorStep (#2087) * feat(normalization): add validation for empty features in NormalizerProcessorStep and UnnormalizerProcessorStep * refactor(normalization): streamline feature reconstruction logic in _NormalizationMixin * refactor(tests): remove unused preprocessor initialization in test_act_backbone_lr --------- Co-authored-by: Pepijn <138571049+pkooij@users.noreply.github.com> --- src/lerobot/processor/normalize_processor.py | 20 +++++++++++--------- tests/policies/test_policies.py | 1 - tests/processor/test_normalize_processor.py | 12 ++++++++++++ 3 files changed, 23 insertions(+), 10 deletions(-) diff --git a/src/lerobot/processor/normalize_processor.py b/src/lerobot/processor/normalize_processor.py index ce69a103..885911ff 100644 --- a/src/lerobot/processor/normalize_processor.py +++ b/src/lerobot/processor/normalize_processor.py @@ -108,16 +108,18 @@ class _NormalizationMixin: """ # Track if stats were explicitly provided (not None and not empty) self._stats_explicitly_provided = self.stats is not None and bool(self.stats) + # Check if self.features is not empty + if not self.features: + raise ValueError("Normalization features cannot be empty") # Robust JSON deserialization handling (guard empty maps). - if self.features: - first_val = next(iter(self.features.values())) - if isinstance(first_val, dict): - reconstructed = {} - for key, ft_dict in self.features.items(): - reconstructed[key] = PolicyFeature( - type=FeatureType(ft_dict["type"]), shape=tuple(ft_dict["shape"]) - ) - self.features = reconstructed + first_val = next(iter(self.features.values())) + if isinstance(first_val, dict): + reconstructed = {} + for key, ft_dict in self.features.items(): + reconstructed[key] = PolicyFeature( + type=FeatureType(ft_dict["type"]), shape=tuple(ft_dict["shape"]) + ) + self.features = reconstructed # if keys are strings (JSON), rebuild enum map if self.norm_map and all(isinstance(k, str) for k in self.norm_map): diff --git a/tests/policies/test_policies.py b/tests/policies/test_policies.py index 34fa8939..07e80d59 100644 --- a/tests/policies/test_policies.py +++ b/tests/policies/test_policies.py @@ -234,7 +234,6 @@ def test_act_backbone_lr(): assert cfg.policy.optimizer_lr_backbone == 0.001 dataset = make_dataset(cfg) - preprocessor, _ = make_pre_post_processors(cfg.policy, None) policy = make_policy(cfg.policy, ds_meta=dataset.meta) optimizer, _ = make_optimizer_and_scheduler(cfg, policy) assert len(optimizer.param_groups) == 2 diff --git a/tests/processor/test_normalize_processor.py b/tests/processor/test_normalize_processor.py index 98c9e0b2..80ac58df 100644 --- a/tests/processor/test_normalize_processor.py +++ b/tests/processor/test_normalize_processor.py @@ -534,6 +534,18 @@ def test_empty_observation(): assert result == transition +def test_empty_features_raises_error(): + """Test that empty features dict raises ValueError.""" + norm_map = {FeatureType.VISUAL: NormalizationMode.MEAN_STD} + stats = {OBS_IMAGE: {"mean": [0.5], "std": [0.2]}} + + with pytest.raises(ValueError, match="Normalization features cannot be empty"): + NormalizerProcessorStep(features={}, norm_map=norm_map, stats=stats) + + with pytest.raises(ValueError, match="Normalization features cannot be empty"): + UnnormalizerProcessorStep(features={}, norm_map=norm_map, stats=stats) + + def test_empty_stats(): features = {OBS_IMAGE: PolicyFeature(FeatureType.VISUAL, (3, 96, 96))} norm_map = {FeatureType.VISUAL: NormalizationMode.MEAN_STD}