|
| 1 | +import json |
| 2 | + |
| 3 | +import pytest |
| 4 | +import torch |
| 5 | + |
| 6 | +from flagscale.models.utils.constants import ( |
| 7 | + ACTION, |
| 8 | + DONE, |
| 9 | + OBS_IMAGE, |
| 10 | + OBS_IMAGES, |
| 11 | + OBS_STATE, |
| 12 | + REWARD, |
| 13 | + TRUNCATED, |
| 14 | +) |
| 15 | +from flagscale.train.processor.batch_processor import ( |
| 16 | + AddBatchDimensionActionStep, |
| 17 | + AddBatchDimensionComplementaryDataStep, |
| 18 | + AddBatchDimensionObservationStep, |
| 19 | + AddBatchDimensionProcessorStep, |
| 20 | +) |
| 21 | +from flagscale.train.processor.converters import ( |
| 22 | + batch_to_transition, |
| 23 | + create_transition, |
| 24 | + identity_transition, |
| 25 | + observation_to_transition, |
| 26 | + policy_action_to_transition, |
| 27 | + robot_action_observation_to_transition, |
| 28 | + robot_action_to_transition, |
| 29 | + to_tensor, |
| 30 | + transition_to_batch, |
| 31 | + transition_to_observation, |
| 32 | + transition_to_policy_action, |
| 33 | + transition_to_robot_action, |
| 34 | +) |
| 35 | +from flagscale.train.processor.core import TransitionKey |
| 36 | +from flagscale.train.processor.device_processor import ( |
| 37 | + DeviceProcessorStep, |
| 38 | + get_safe_torch_device, |
| 39 | +) |
| 40 | +from flagscale.train.processor.factory import ( |
| 41 | + make_default_processors, |
| 42 | + make_default_robot_action_processor, |
| 43 | + make_default_robot_observation_processor, |
| 44 | + make_default_teleop_action_processor, |
| 45 | +) |
| 46 | +from flagscale.train.processor.pipeline import ( |
| 47 | + DataProcessorPipeline, |
| 48 | + IdentityProcessorStep, |
| 49 | + InfoProcessorStep, |
| 50 | + ProcessorMigrationError, |
| 51 | + ProcessorStep, |
| 52 | + ProcessorStepRegistry, |
| 53 | +) |
| 54 | +from flagscale.train.processor.rename_processor import ( |
| 55 | + RenameObservationsProcessorStep, |
| 56 | + rename_stats, |
| 57 | +) |
| 58 | + |
| 59 | + |
| 60 | +class AppendInfoStep(InfoProcessorStep): |
| 61 | + def __init__(self, value="seen"): |
| 62 | + self.value = value |
| 63 | + self.reset_called = False |
| 64 | + |
| 65 | + def info(self, info): |
| 66 | + info["marker"] = self.value |
| 67 | + return info |
| 68 | + |
| 69 | + def get_config(self): |
| 70 | + return {"value": self.value} |
| 71 | + |
| 72 | + def reset(self): |
| 73 | + self.reset_called = True |
| 74 | + |
| 75 | + def transform_features(self, features): |
| 76 | + return {**features, "transformed": True} |
| 77 | + |
| 78 | + |
| 79 | +class RegistryOnlyStep(ProcessorStep): |
| 80 | + def __call__(self, transition): |
| 81 | + return transition |
| 82 | + |
| 83 | + def transform_features(self, features): |
| 84 | + return features |
| 85 | + |
| 86 | + |
| 87 | +def test_to_tensor_converts_supported_inputs_and_rejects_unknown_types(): |
| 88 | + assert to_tensor(torch.tensor([1]), dtype=torch.float64).dtype == torch.float64 |
| 89 | + assert to_tensor([1, 2]).shape == (2,) |
| 90 | + assert to_tensor((1, 2), dtype=torch.int64).dtype == torch.int64 |
| 91 | + assert to_tensor({"a": 1, "b": None, "c": {"d": 2}})["c"]["d"].item() == 2 |
| 92 | + assert to_tensor({}) == {} |
| 93 | + |
| 94 | + with pytest.raises(TypeError): |
| 95 | + to_tensor(object()) |
| 96 | + |
| 97 | + |
| 98 | +def test_transition_converters_cover_success_and_validation_paths(): |
| 99 | + action = {"joint": [1.0]} |
| 100 | + obs = {"camera": "frame"} |
| 101 | + transition = robot_action_observation_to_transition((action, obs)) |
| 102 | + |
| 103 | + assert transition_to_robot_action(transition) == action |
| 104 | + assert transition_to_observation(transition) == obs |
| 105 | + assert robot_action_to_transition(action)[TransitionKey.ACTION] == action |
| 106 | + assert observation_to_transition(obs)[TransitionKey.OBSERVATION] == obs |
| 107 | + |
| 108 | + tensor_action = torch.ones(2) |
| 109 | + policy_transition = policy_action_to_transition(tensor_action) |
| 110 | + assert torch.equal(transition_to_policy_action(policy_transition), tensor_action) |
| 111 | + assert identity_transition(policy_transition) is policy_transition |
| 112 | + |
| 113 | + with pytest.raises(ValueError): |
| 114 | + robot_action_observation_to_transition([action, obs]) |
| 115 | + with pytest.raises(ValueError): |
| 116 | + robot_action_observation_to_transition((torch.ones(1), obs)) |
| 117 | + with pytest.raises(ValueError): |
| 118 | + robot_action_to_transition(torch.ones(1)) |
| 119 | + with pytest.raises(ValueError): |
| 120 | + observation_to_transition("bad") |
| 121 | + with pytest.raises(ValueError): |
| 122 | + transition_to_robot_action(create_transition(action=torch.ones(1))) |
| 123 | + with pytest.raises(ValueError): |
| 124 | + transition_to_policy_action(create_transition(action={"bad": 1})) |
| 125 | + with pytest.raises(ValueError): |
| 126 | + transition_to_observation(create_transition(observation=None)) |
| 127 | + |
| 128 | + |
| 129 | +def test_batch_transition_round_trip_extracts_observation_and_complementary_data(): |
| 130 | + batch = { |
| 131 | + OBS_STATE: torch.ones(3), |
| 132 | + ACTION: torch.zeros(2), |
| 133 | + REWARD: torch.tensor(1.0), |
| 134 | + DONE: torch.tensor(False), |
| 135 | + TRUNCATED: torch.tensor(True), |
| 136 | + "task": "pick", |
| 137 | + "index": torch.tensor(5), |
| 138 | + "padding_is_pad": torch.tensor(False), |
| 139 | + "info": {"episode": 1}, |
| 140 | + } |
| 141 | + |
| 142 | + transition = batch_to_transition(batch) |
| 143 | + assert transition[TransitionKey.OBSERVATION][OBS_STATE] is batch[OBS_STATE] |
| 144 | + assert transition[TransitionKey.COMPLEMENTARY_DATA]["task"] == "pick" |
| 145 | + |
| 146 | + restored = transition_to_batch(transition) |
| 147 | + assert restored[OBS_STATE] is batch[OBS_STATE] |
| 148 | + assert restored["task"] == "pick" |
| 149 | + |
| 150 | + with pytest.raises(ValueError): |
| 151 | + batch_to_transition("bad") |
| 152 | + with pytest.raises(ValueError): |
| 153 | + batch_to_transition({ACTION: {"not": "policy-action"}}) |
| 154 | + with pytest.raises(ValueError): |
| 155 | + transition_to_batch("bad") |
| 156 | + |
| 157 | + |
| 158 | +def test_processor_registry_register_get_unregister_and_duplicate_errors(): |
| 159 | + registry_name = "unit_test_identity_step" |
| 160 | + ProcessorStepRegistry.unregister(registry_name) |
| 161 | + |
| 162 | + decorated = ProcessorStepRegistry.register(registry_name)(RegistryOnlyStep) |
| 163 | + assert decorated is RegistryOnlyStep |
| 164 | + assert ProcessorStepRegistry.get(registry_name) is RegistryOnlyStep |
| 165 | + assert registry_name in ProcessorStepRegistry.list() |
| 166 | + |
| 167 | + with pytest.raises(ValueError, match="already registered"): |
| 168 | + ProcessorStepRegistry.register(registry_name)(RegistryOnlyStep) |
| 169 | + |
| 170 | + ProcessorStepRegistry.unregister(registry_name) |
| 171 | + with pytest.raises(KeyError): |
| 172 | + ProcessorStepRegistry.get(registry_name) |
| 173 | + |
| 174 | + |
| 175 | +def test_data_processor_pipeline_hooks_slicing_processing_and_reset(): |
| 176 | + step = AppendInfoStep("ok") |
| 177 | + calls = [] |
| 178 | + pipeline = DataProcessorPipeline( |
| 179 | + steps=[step, IdentityProcessorStep()], |
| 180 | + name="demo pipeline", |
| 181 | + ) |
| 182 | + pipeline.register_before_step_hook(lambda idx, transition: calls.append(("before", idx))) |
| 183 | + pipeline.register_after_step_hook(lambda idx, transition: calls.append(("after", idx))) |
| 184 | + |
| 185 | + result = pipeline({"info": {"start": True}}) |
| 186 | + |
| 187 | + assert result["info"]["marker"] == "ok" |
| 188 | + assert calls == [("before", 0), ("after", 0), ("before", 1), ("after", 1)] |
| 189 | + assert len(pipeline) == 2 |
| 190 | + assert isinstance(pipeline[0], AppendInfoStep) |
| 191 | + assert isinstance(pipeline[:1], DataProcessorPipeline) |
| 192 | + assert "steps=2" in repr(pipeline) |
| 193 | + assert ( |
| 194 | + list(pipeline.step_through({"info": {"start": True}}))[-1][TransitionKey.INFO]["marker"] |
| 195 | + == "ok" |
| 196 | + ) |
| 197 | + assert pipeline.process_info({"x": 1})["marker"] == "ok" |
| 198 | + assert pipeline.transform_features({})["transformed"] is True |
| 199 | + |
| 200 | + pipeline.reset() |
| 201 | + assert step.reset_called is True |
| 202 | + |
| 203 | + with pytest.raises(TypeError): |
| 204 | + DataProcessorPipeline(steps=[object()]) |
| 205 | + with pytest.raises(ValueError): |
| 206 | + pipeline.unregister_before_step_hook(lambda idx, transition: None) |
| 207 | + with pytest.raises(ValueError): |
| 208 | + pipeline.unregister_after_step_hook(lambda idx, transition: None) |
| 209 | + |
| 210 | + |
| 211 | +def test_pipeline_save_load_config_validation_and_migration_errors(tmp_path): |
| 212 | + pipeline = DataProcessorPipeline( |
| 213 | + steps=[IdentityProcessorStep()], |
| 214 | + name="Policy Preprocessor", |
| 215 | + ) |
| 216 | + pipeline.save_pretrained(tmp_path, config_filename="processor.json") |
| 217 | + |
| 218 | + config_path = tmp_path / "processor.json" |
| 219 | + config = json.loads(config_path.read_text(encoding="utf-8")) |
| 220 | + assert config["name"] == "Policy Preprocessor" |
| 221 | + assert config["steps"][0]["class"].endswith("IdentityProcessorStep") |
| 222 | + |
| 223 | + loaded_from_dir = DataProcessorPipeline.from_pretrained(tmp_path, "processor.json") |
| 224 | + loaded_from_file = DataProcessorPipeline.from_pretrained(config_path, "ignored.json") |
| 225 | + assert isinstance(loaded_from_dir.steps[0], IdentityProcessorStep) |
| 226 | + assert isinstance(loaded_from_file.steps[0], IdentityProcessorStep) |
| 227 | + |
| 228 | + assert DataProcessorPipeline._is_processor_config({"steps": []}) is True |
| 229 | + assert DataProcessorPipeline._is_processor_config({"steps": [{}]}) is False |
| 230 | + assert DataProcessorPipeline._is_processor_config({"steps": "bad"}) is False |
| 231 | + |
| 232 | + with pytest.raises(KeyError, match="Override keys"): |
| 233 | + DataProcessorPipeline.from_pretrained( |
| 234 | + tmp_path, "processor.json", overrides={"missing_step": {}} |
| 235 | + ) |
| 236 | + |
| 237 | + invalid_dir = tmp_path / "invalid" |
| 238 | + invalid_dir.mkdir() |
| 239 | + (invalid_dir / "config.json").write_text(json.dumps({"model_type": "old"}), encoding="utf-8") |
| 240 | + assert DataProcessorPipeline._should_suggest_migration(invalid_dir) is True |
| 241 | + with pytest.raises(ProcessorMigrationError): |
| 242 | + DataProcessorPipeline.from_pretrained(invalid_dir, "processor.json") |
| 243 | + |
| 244 | + |
| 245 | +def test_default_factory_processors_are_identity_pipelines(): |
| 246 | + teleop = make_default_teleop_action_processor() |
| 247 | + robot_action = make_default_robot_action_processor() |
| 248 | + robot_obs = make_default_robot_observation_processor() |
| 249 | + all_processors = make_default_processors() |
| 250 | + |
| 251 | + action = {"joint": 1} |
| 252 | + observation = {"camera": "frame"} |
| 253 | + assert teleop((action, observation)) == action |
| 254 | + assert robot_action((action, observation)) == action |
| 255 | + assert robot_obs(observation) == observation |
| 256 | + assert len(all_processors) == 3 |
| 257 | + assert all(len(processor.steps) == 1 for processor in all_processors) |
| 258 | + assert all( |
| 259 | + isinstance(processor.steps[0], IdentityProcessorStep) for processor in all_processors |
| 260 | + ) |
| 261 | + |
| 262 | + |
| 263 | +def test_batch_dimension_processor_steps_add_expected_leading_dimensions(): |
| 264 | + action = torch.ones(3) |
| 265 | + obs = { |
| 266 | + OBS_STATE: torch.ones(4), |
| 267 | + OBS_IMAGE: torch.ones(3, 8, 8), |
| 268 | + f"{OBS_IMAGES}.cam": torch.ones(3, 4, 4), |
| 269 | + "already_batched": torch.ones(2, 3), |
| 270 | + } |
| 271 | + comp = {"task": "pick", "index": torch.tensor(1), "task_index": torch.tensor(2)} |
| 272 | + |
| 273 | + assert AddBatchDimensionActionStep().action(action).shape == (1, 3) |
| 274 | + processed_obs = AddBatchDimensionObservationStep().observation(obs) |
| 275 | + assert processed_obs[OBS_STATE].shape == (1, 4) |
| 276 | + assert processed_obs[OBS_IMAGE].shape == (1, 3, 8, 8) |
| 277 | + assert processed_obs[f"{OBS_IMAGES}.cam"].shape == (1, 3, 4, 4) |
| 278 | + processed_comp = AddBatchDimensionComplementaryDataStep().complementary_data(comp) |
| 279 | + assert processed_comp["task"] == ["pick"] |
| 280 | + assert processed_comp["index"].shape == (1,) |
| 281 | + |
| 282 | + transition = create_transition( |
| 283 | + observation={OBS_STATE: torch.ones(2)}, |
| 284 | + action=torch.ones(2), |
| 285 | + complementary_data={"task": "place"}, |
| 286 | + ) |
| 287 | + processed = AddBatchDimensionProcessorStep()(transition) |
| 288 | + assert processed[TransitionKey.ACTION].shape == (1, 2) |
| 289 | + assert processed[TransitionKey.OBSERVATION][OBS_STATE].shape == (1, 2) |
| 290 | + assert processed[TransitionKey.COMPLEMENTARY_DATA]["task"] == ["place"] |
| 291 | + |
| 292 | + |
| 293 | +def test_device_processor_cpu_dtype_config_and_validation(monkeypatch): |
| 294 | + step = DeviceProcessorStep(device="cpu", float_dtype="float64") |
| 295 | + transition = create_transition( |
| 296 | + observation={OBS_STATE: torch.ones(2, dtype=torch.float32), "text": "keep"}, |
| 297 | + action=torch.ones(2, dtype=torch.float32), |
| 298 | + reward=torch.tensor(1.0), |
| 299 | + done=torch.tensor(False), |
| 300 | + complementary_data={"index": torch.tensor(1)}, |
| 301 | + ) |
| 302 | + |
| 303 | + processed = step(transition) |
| 304 | + assert processed[TransitionKey.ACTION].dtype == torch.float64 |
| 305 | + assert processed[TransitionKey.OBSERVATION][OBS_STATE].dtype == torch.float64 |
| 306 | + assert processed[TransitionKey.OBSERVATION]["text"] == "keep" |
| 307 | + assert step.get_config() == {"device": "cpu", "float_dtype": "float64"} |
| 308 | + assert get_safe_torch_device("cpu").type == "cpu" |
| 309 | + |
| 310 | + with pytest.raises(ValueError, match="Invalid float_dtype"): |
| 311 | + DeviceProcessorStep(device="cpu", float_dtype="bad") |
| 312 | + with pytest.raises(ValueError, match="PolicyAction"): |
| 313 | + step(create_transition(action={"robot": 1})) |
| 314 | + |
| 315 | + |
| 316 | +def test_rename_observations_processor_and_stats_do_not_mutate_inputs(): |
| 317 | + step = RenameObservationsProcessorStep(rename_map={"old": "new"}) |
| 318 | + original_obs = {"old": torch.tensor(1), "keep": torch.tensor(2)} |
| 319 | + |
| 320 | + processed = step.observation(original_obs) |
| 321 | + assert set(processed) == {"new", "keep"} |
| 322 | + assert step.get_config() == {"rename_map": {"old": "new"}} |
| 323 | + |
| 324 | + stats = {"old": {"mean": [1]}, "keep": None} |
| 325 | + renamed = rename_stats(stats, {"old": "new"}) |
| 326 | + renamed["new"]["mean"].append(2) |
| 327 | + assert stats["old"]["mean"] == [1] |
| 328 | + assert renamed["keep"] == {} |
| 329 | + assert rename_stats({}, {"old": "new"}) == {} |
0 commit comments