Spaces:
Running on Zero
Running on Zero
Download tcp4d/action_normalization.py from HarrisonPENG/TCP4D-DROID: direct link, hf CLI and curl.
- Browser
- Download file 17.7 kB
-
https://huggingface.co/spaces/HarrisonPENG/TCP4D-DROID/resolve/main/tcp4d/action_normalization.py
- Command line
-
hf download hf://spaces/HarrisonPENG/TCP4D-DROID/tcp4d/action_normalization.py
-
curl -L -o action_normalization.py https://huggingface.co/spaces/HarrisonPENG/TCP4D-DROID/resolve/main/tcp4d/action_normalization.py
17.7 kB
| """Componentwise Stage2 action statistics, portable artifacts, and initialization.""" | |
| from __future__ import annotations | |
| from contextlib import ExitStack | |
| import json | |
| import logging | |
| import math | |
| from pathlib import Path | |
| from tempfile import TemporaryDirectory | |
| import time | |
| import numpy as np | |
| import torch | |
| from tcp4d.action import future_actions_in_current_camera | |
| LOGGER = logging.getLogger(__name__) | |
| NORM_FILENAME = "action_norm.json" | |
| NORM_METHOD = "componentwise" | |
| NORM_VERSION = 2 | |
| POSITION_FALLBACK_SPAN = 1e-4 | |
| POSITION_MIN_SPAN = 1e-3 | |
| STAT_NAMES = ("mean", "std", "q01", "q99") | |
| ACTION_COMPONENTS = ["x", "y", "z", "rotation6d_0", "rotation6d_1", "rotation6d_2", | |
| "rotation6d_3", "rotation6d_4", "rotation6d_5", "gripper"] | |
| class ActionNormalizationError(ValueError): | |
| """Invalid action shapes, normalization parameters, or checkpoint metadata.""" | |
| def configure_action_normalization(config): | |
| """New runs have exactly one action normalization format.""" | |
| config.setdefault("action_normalization", NORM_METHOD) | |
| config.setdefault("action_normalization_version", NORM_VERSION) | |
| # Fitting the complete dataset on rank zero can exceed the default process | |
| # group timeout. This also covers index/profile construction before fitting. | |
| config.setdefault("distributed_timeout_seconds", 86400) | |
| timeout = config["distributed_timeout_seconds"] | |
| if not isinstance(timeout, (int, float)) or not math.isfinite(timeout) or timeout <= 0: | |
| raise ValueError("distributed_timeout_seconds must be finite and positive") | |
| require_action_normalization_config(config) | |
| def require_action_normalization_config(config): | |
| pair = (config.get("action_normalization"), config.get("action_normalization_version")) | |
| if pair != (NORM_METHOD, NORM_VERSION): | |
| raise ActionNormalizationError( | |
| "Stage2 requires componentwise action normalization v2; " | |
| "start new componentwise runs from Stage1." | |
| ) | |
| def componentwise_parameters(q01, q99, minimum, maximum): | |
| """XYZ affine parameters; rotation columns and gripper retain their units.""" | |
| q01, q99, minimum, maximum = [np.asarray(v, dtype=np.float64) for v in (q01, q99, minimum, maximum)] | |
| if (q01.ndim != 2 or q01.shape[0] != 1 or q01.shape[1] not in (10, 20) | |
| or any(v.shape != q01.shape or not np.isfinite(v).all() for v in (q01, q99, minimum, maximum)) | |
| or (minimum > q01).any() or (q01 > q99).any() or (q99 > maximum).any()): | |
| raise ActionNormalizationError("Invalid componentwise action statistics") | |
| fallback = q99 - q01 < POSITION_FALLBACK_SPAN | |
| low, high = np.where(fallback, minimum, q01), np.where(fallback, maximum, q99) | |
| # Each arm contributes xyz + rotation6D + gripper. Fit affine scaling only | |
| # to XYZ; rotation directions and the [-1,1] gripper score retain their units. | |
| position = np.arange(q01.shape[1]) % 10 < 3 | |
| offset, scale = np.zeros_like(q01), np.ones_like(q01) | |
| offset[:, position] = (low[:, position] + high[:, position]) / 2 | |
| scale[:, position] = np.maximum(high[:, position] - low[:, position], POSITION_MIN_SPAN) / 2 | |
| return offset, scale | |
| def _affine_parameters(action, offset, scale): | |
| if torch.is_tensor(action): | |
| if not action.is_floating_point(): | |
| raise ActionNormalizationError("Actions must use a floating dtype") | |
| action = action.float() | |
| offset = torch.as_tensor(offset, dtype=torch.float32, device=action.device) | |
| scale = torch.as_tensor(scale, dtype=torch.float32, device=action.device) | |
| finite = torch.isfinite | |
| else: | |
| action = np.asarray(action) | |
| if not np.issubdtype(action.dtype, np.floating): | |
| raise ActionNormalizationError("Actions must use a floating dtype") | |
| offset, scale = np.asarray(offset, dtype=np.float32), np.asarray(scale, dtype=np.float32) | |
| finite = np.isfinite | |
| if (action.ndim < 2 or offset.shape != (1, action.shape[-1]) or scale.shape != offset.shape | |
| or not bool(finite(offset).all() & finite(scale).all() & (scale > 0).all())): | |
| raise ActionNormalizationError("Expected finite affine [1,D] parameters with positive scales") | |
| return action, offset, scale | |
| def componentwise_normalize(action, offset, scale): | |
| action, offset, scale = _affine_parameters(action, offset, scale) | |
| return (action - offset) / scale | |
| def componentwise_unnormalize(action, offset, scale): | |
| action, offset, scale = _affine_parameters(action, offset, scale) | |
| return action * scale + offset | |
| def action_layout(num_arms): | |
| arms = ["left", "right"] if num_arms == 2 else [f"arm_{i}" for i in range(num_arms)] | |
| return [f"{arm}.{component}" for arm in arms for component in ACTION_COMPONENTS] | |
| def iter_future_action_rows(states, extrinsics, anchors, segment_ends, horizon, *, chunk_size=8192): | |
| """Yield every real (anchor, future offset) label as float32 [N,A*10]. | |
| This uses the same coordinate/rotation/gripper conversion as sampled labels. | |
| Extrinsics may be a static camera matrix or a per-frame camera trajectory. | |
| No padded rows or image reads are involved. | |
| """ | |
| anchors = np.asarray(anchors, dtype=np.int64) | |
| segment_ends = np.asarray(segment_ends, dtype=np.int64) | |
| if anchors.shape != segment_ends.shape or horizon < 1 or chunk_size < 1: | |
| raise ValueError("Invalid action statistics window specification") | |
| static_camera = np.ndim(extrinsics) == 2 | |
| for offset in range(1, horizon + 1): | |
| selected = anchors[anchors + offset < segment_ends] | |
| for begin in range(0, len(selected), chunk_size): | |
| current_indices = selected[begin:begin + chunk_size] | |
| future_indices = current_indices + offset | |
| state = torch.tensor(np.asarray(states[future_indices]), dtype=torch.float32) | |
| if static_camera: | |
| current = torch.tensor(np.asarray(extrinsics), dtype=torch.float32) | |
| future = current.expand(len(future_indices), -1, -1) | |
| else: | |
| current = torch.tensor(np.asarray(extrinsics[current_indices]), dtype=torch.float32) | |
| future = torch.tensor(np.asarray(extrinsics[future_indices]), dtype=torch.float32) | |
| yield future_actions_in_current_camera(state, future, current).flatten(-2).numpy() | |
| def validate_action_norm(record, num_arms): | |
| require_action_normalization_config({"action_normalization": record.get("method"), | |
| "action_normalization_version": record.get("version")}) | |
| expected = {"horizon_shared": True, "num_arms": num_arms, "action_dim": num_arms * 10, | |
| "layout": action_layout(num_arms), "statistics_scope": "train_valid_window_actions"} | |
| expected.update(position_fallback_span=POSITION_FALLBACK_SPAN, position_min_span=POSITION_MIN_SPAN, | |
| component_rules=["quantile_xyz", "identity_rotation6d", "identity_gripper"]) | |
| for key, value in expected.items(): | |
| if record.get(key) != value: | |
| raise ActionNormalizationError(f"Invalid action normalization {key}: expected {value!r}") | |
| if not isinstance(record.get("count"), int) or record["count"] < 1: | |
| raise ActionNormalizationError("Action normalization requires a positive sample count") | |
| stats = {} | |
| for name in (*STAT_NAMES, "min", "max", "offset", "scale"): | |
| try: | |
| value = np.asarray(record[name], dtype=np.float64) | |
| except (KeyError, TypeError, ValueError) as exc: | |
| raise ActionNormalizationError(f"Invalid action normalization {name}") from exc | |
| if value.shape != (1, num_arms * 10) or not np.isfinite(value).all(): | |
| raise ActionNormalizationError(f"{name} must contain finite [1,{num_arms * 10}] statistics") | |
| stats[name] = value | |
| if (stats["std"] < 0).any() or (stats["q99"] < stats["q01"]).any(): | |
| raise ActionNormalizationError("Invalid standard deviation or reversed quantiles") | |
| offset, scale = componentwise_parameters(stats["q01"], stats["q99"], stats["min"], stats["max"]) | |
| for name, value in (("offset", offset), ("scale", scale)): | |
| if not np.array_equal(stats[name].astype(np.float32), value.astype(np.float32)): | |
| raise ActionNormalizationError(f"Saved {name} disagrees with componentwise rules") | |
| scale = scale.astype(np.float32) | |
| if not np.isfinite(offset.astype(np.float32)).all(): | |
| raise ActionNormalizationError("Float32 offset must be finite") | |
| if not np.isfinite(scale).all() or not (scale > 0).all(): | |
| raise ActionNormalizationError("Float32 normalization scale must be finite and positive") | |
| return stats | |
| def compute_action_norm(mixture, num_arms, scratch_dir): | |
| """Exact XYZ-only quantiles with bounded RAM and one temporary file per XYZ axis. | |
| Every valid window action has equal weight, regardless of sampler weights. | |
| In-place partition on contiguous memory-mapped columns avoids a full RAM copy. | |
| """ | |
| dimension = num_arms * 10 | |
| position_indices = np.flatnonzero(np.arange(dimension) % 10 < 3) | |
| statistics_dim = len(position_indices) | |
| layout = action_layout(num_arms) | |
| minimum, maximum = np.full(statistics_dim, np.inf), np.full(statistics_dim, -np.inf) | |
| count = 0 | |
| mean, m2 = np.zeros(statistics_dim), np.zeros(statistics_dim) | |
| sources = [] | |
| last_progress = time.monotonic() | |
| with TemporaryDirectory(prefix=".action-norm-", dir=scratch_dir) as temporary: | |
| paths = [Path(temporary) / f"xyz-{i}.bin" for i in position_indices] | |
| with ExitStack() as stack: | |
| handles = [stack.enter_context(path.open("wb")) for path in paths] | |
| for source in mixture.sources: | |
| if source.weight <= 0: | |
| continue | |
| dataset = source.dataset | |
| if dataset.split != "train": | |
| raise ActionNormalizationError("Quantile fitting may only use the training split") | |
| source_count = 0 | |
| LOGGER.info("Fit XYZ normalization: source=%s, streams=%d, position dimensions=%d", | |
| source.name, len(dataset), statistics_dim) | |
| for values in dataset.iter_action_targets(): | |
| values = np.asarray(values, dtype=np.float32) | |
| if values.ndim != 2 or values.shape[1] != dimension or not np.isfinite(values).all(): | |
| raise ActionNormalizationError(f"Invalid action labels in source {source.name}") | |
| size = len(values) | |
| if not size: | |
| continue | |
| values = values[:, position_indices] | |
| minimum = np.minimum(minimum, values.min(axis=0)) | |
| maximum = np.maximum(maximum, values.max(axis=0)) | |
| batch = values.astype(np.float64) | |
| batch_mean = batch.mean(axis=0) | |
| delta = batch_mean - mean | |
| new_count = count + size | |
| m2 += np.square(batch - batch_mean).sum(axis=0) + delta**2 * (count * size / new_count) | |
| mean += delta * (size / new_count) | |
| count = new_count | |
| source_count += size | |
| for column, handle in enumerate(handles): | |
| np.ascontiguousarray(values[:, column]).tofile(handle) | |
| if time.monotonic() - last_progress >= 30: | |
| LOGGER.info("Fit XYZ normalization: %d labels, %.2f GiB temporary XYZ columns", | |
| count, count * statistics_dim * 4 / 1024**3) | |
| last_progress = time.monotonic() | |
| LOGGER.info("Fit XYZ normalization: source=%s, valid window actions=%d", source.name, source_count) | |
| sources.append({"name": source.name, "count": source_count, | |
| "history_frames": dataset.history_frames, | |
| "prediction_horizon": dataset.prediction_horizon, | |
| "selection": dataset.manifest()}) | |
| if count == 0: | |
| raise ActionNormalizationError("No valid training actions for XYZ normalization") | |
| ranks = np.array([0.01, 0.99]) * (count - 1) | |
| lower, upper = np.floor(ranks).astype(np.int64), np.ceil(ranks).astype(np.int64) | |
| quantiles = np.empty((2, statistics_dim), dtype=np.float64) | |
| for column, path in enumerate(paths): | |
| LOGGER.info("Select exact XYZ quantiles: %s (%d/%d)", | |
| layout[position_indices[column]], column + 1, statistics_dim) | |
| values = np.memmap(path, mode="r+", dtype=np.float32, shape=(count,)) | |
| values.partition(np.unique(np.r_[lower, upper])) | |
| lo, hi = values[lower].astype(np.float64), values[upper].astype(np.float64) | |
| quantiles[:, column] = lo + (hi - lo) * (ranks - lower) | |
| del values | |
| def action_columns(values): | |
| # Keep the action-aligned artifact layout. Non-XYZ slots are unused zeros, | |
| # not measured statistics; their actual affine transform stays (0, 1). | |
| expanded = np.zeros((*values.shape[:-1], dimension), dtype=np.float64) | |
| expanded[..., position_indices] = values | |
| return expanded | |
| mean, m2 = action_columns(mean), action_columns(m2) | |
| minimum, maximum = action_columns(minimum), action_columns(maximum) | |
| quantiles = action_columns(quantiles) | |
| record = { | |
| "version": NORM_VERSION, "method": NORM_METHOD, | |
| "horizon_shared": True, "num_arms": num_arms, "action_dim": dimension, | |
| "layout": action_layout(num_arms), "statistics_scope": "train_valid_window_actions", | |
| "count": count, "sources": sources, | |
| "mean": mean[None].tolist(), "std": np.sqrt(np.maximum(m2 / count, 0))[None].tolist(), | |
| "q01": quantiles[0:1].tolist(), "q99": quantiles[1:2].tolist(), | |
| } | |
| offset, scale = componentwise_parameters(quantiles[0:1], quantiles[1:2], minimum[None], maximum[None]) | |
| record.update(min=minimum[None].tolist(), max=maximum[None].tolist(), | |
| offset=offset.tolist(), scale=scale.tolist(), | |
| position_fallback_span=POSITION_FALLBACK_SPAN, position_min_span=POSITION_MIN_SPAN, | |
| component_rules=["quantile_xyz", "identity_rotation6d", "identity_gripper"]) | |
| validate_action_norm(record, num_arms) | |
| return record | |
| def save_action_norm(directory, record): | |
| validate_action_norm(record, record["num_arms"]) | |
| path = Path(directory) / NORM_FILENAME | |
| temporary = path.with_suffix(".json.tmp") | |
| temporary.write_text(json.dumps(record, indent=2, allow_nan=False), encoding="utf-8") | |
| temporary.replace(path) | |
| def load_checkpoint_action_norm(checkpoint, num_arms): | |
| path = Path(checkpoint).expanduser() | |
| directory = path if path.is_dir() else path.parent | |
| config_path = directory / "config.json" | |
| if not config_path.is_file(): | |
| raise ActionNormalizationError(f"Componentwise Stage2 checkpoint requires {config_path}") | |
| config = json.loads(config_path.read_text(encoding="utf-8")) | |
| require_action_normalization_config(config) | |
| path = directory / NORM_FILENAME | |
| if not path.is_file(): | |
| raise ActionNormalizationError(f"Componentwise Stage2 checkpoint requires {path}") | |
| record = json.loads(path.read_text(encoding="utf-8")) | |
| if (record.get("method"), record.get("version")) != (config["action_normalization"], config["action_normalization_version"]): | |
| raise ActionNormalizationError("Checkpoint config and action_norm.json versions disagree") | |
| validate_action_norm(record, num_arms) | |
| return record | |
| def assert_action_norm_matches(policy, record): | |
| stats = validate_action_norm(record, policy.num_arms) | |
| for name in ("offset", "scale"): | |
| actual = getattr(policy, f"action_{name}").detach().float().cpu() | |
| expected = torch.tensor(stats[name], dtype=torch.float32) | |
| if actual.shape != expected.shape or not torch.equal(actual, expected): | |
| raise ActionNormalizationError(f"Checkpoint action_{name} differs from {NORM_FILENAME}") | |
| def initialize_action_normalization(policy, dataset, config, accelerator): | |
| """Fit only on rank zero, or reuse a checkpoint; publish one identical record.""" | |
| from accelerate.utils import broadcast_object_list | |
| checkpoint = config.get("resume") | |
| result = [None] | |
| if accelerator.is_main_process: | |
| try: | |
| root = Path(config["output_dir"]) | |
| root.mkdir(parents=True, exist_ok=True) | |
| if checkpoint: | |
| record = load_checkpoint_action_norm(checkpoint, policy.num_arms) | |
| assert_action_norm_matches(policy, record) | |
| else: | |
| record = compute_action_norm(dataset, policy.num_arms, root) | |
| save_action_norm(root, record) | |
| result[0] = {"record": record} | |
| except Exception as exc: | |
| # Broadcast failures too, so other ranks do not wait for missing data. | |
| result[0] = {"error": f"{type(exc).__name__}: {exc}"} | |
| broadcast_object_list(result) | |
| if "error" in result[0]: | |
| raise ActionNormalizationError(result[0]["error"]) | |
| record = result[0]["record"] | |
| if checkpoint: | |
| assert_action_norm_matches(policy, record) | |
| else: | |
| policy.set_action_component_stats(record["offset"], record["scale"]) | |
| LOGGER.info("Action normalization: %s, %d XYZ dimensions scaled; rotation6D/gripper unchanged; " | |
| "%d valid training window actions%s", record["method"], policy.num_arms * 3, | |
| record["count"], " (reused checkpoint)" if checkpoint else "") | |
| return record | |