TCP4D-DROID / tcp4d /state_normalization.py
HarrisonPENG's picture
Deploy TCP4D DROID interactive ZeroGPU demo
95ee94a verified
Raw History Blame Contribute Delete
7.66 kB
"""Real RoboTwin joint/gripper state IO and train-anchor-only normalization."""
from __future__ import annotations
from contextlib import ExitStack
import json
from pathlib import Path
from tempfile import TemporaryDirectory
import numpy as np
import torch
STATE_NORM_FILENAME = "state_norm.json"
STATE_LAYOUT = [f"{arm}.{name}" for arm in ("left", "right")
for name in (*[f"joint_{i}" for i in range(6)], "gripper")]
JOINT_INDICES = [i for i in range(14) if i not in (6, 13)]
def read_robot_states(episode_path, num_frames=None):
path = Path(episode_path) / "robot_state.npy"
if not path.is_file():
raise ValueError(f"V2 requires real current robot state: missing {path}")
state = np.load(path, mmap_mode="r", allow_pickle=False)
if state.ndim != 2 or state.shape[1] != 14 or (num_frames is not None and len(state) != num_frames):
raise ValueError(f"Expected frame-aligned robot_state.npy [T,14]: {path}, got {state.shape}")
return state
def validate_robot_states(values):
values = np.asarray(values)
if values.ndim != 2 or values.shape[1] != 14 or not np.isfinite(values).all():
raise ValueError("Current real robot states must be finite [N,14]")
if np.any((values[:, [6, 13]] < 0) | (values[:, [6, 13]] > 1)):
raise ValueError("Current real robot state grippers must be in [0,1]")
def state_parameters(q01, q99, minimum, maximum):
q01, q99, minimum, maximum = [np.asarray(x, dtype=np.float64) for x in (q01, q99, minimum, maximum)]
if any(x.shape != (1, 12) or not np.isfinite(x).all() for x in (q01, q99, minimum, maximum)):
raise ValueError("State joint statistics must be finite [1,12]")
if np.any((minimum > q01) | (q01 > q99) | (q99 > maximum)):
raise ValueError("Invalid state quantile bounds")
fallback = q99 - q01 < 1e-4
low, high = np.where(fallback, minimum, q01), np.where(fallback, maximum, q99)
offset, scale = np.full((1, 14), 0.5), np.full((1, 14), 0.5)
offset[:, JOINT_INDICES] = (low + high) / 2
scale[:, JOINT_INDICES] = np.maximum(high - low, 1e-3) / 2
return offset, scale
def validate_state_norm(record):
expected = {"version": 1, "layout": STATE_LAYOUT, "statistics_scope": "train_valid_current_frames",
"joint_indices": JOINT_INDICES, "minimum_span": 1e-3, "fallback_span": 1e-4}
for key, value in expected.items():
if record.get(key) != value:
raise ValueError(f"Invalid state normalization {key}")
if not isinstance(record.get("count"), int) or record["count"] < 1:
raise ValueError("State statistics require valid training frames")
offset, scale = state_parameters(*(record[key] for key in ("q01", "q99", "min", "max")))
for name, expected in (("offset", offset), ("scale", scale)):
actual = np.asarray(record[name], dtype=np.float32)
if actual.shape != (1, 14) or not np.isfinite(actual).all() or not np.array_equal(actual, expected.astype(np.float32)):
raise ValueError(f"State normalization {name} disagrees with quantile/gripper rules")
return record
def compute_state_norm(mixture, scratch_dir):
"""Exact quantiles per joint, bounded RAM; each eligible current frame once."""
count, sources = 0, []
minimum, maximum = np.full(12, np.inf), np.full(12, -np.inf)
with TemporaryDirectory(prefix=".state-norm-", dir=scratch_dir) as tmp:
paths = [Path(tmp) / f"joint-{i}.bin" for i in range(12)]
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
if source.dataset.split != "train":
raise ValueError("State normalization may only use the training split")
source_count = 0
for values in source.dataset.iter_current_robot_states():
validate_robot_states(values)
joints = np.asarray(values, dtype=np.float32)[:, JOINT_INDICES]
if not len(joints):
continue
minimum, maximum = np.minimum(minimum, joints.min(0)), np.maximum(maximum, joints.max(0))
count += len(joints)
source_count += len(joints)
for column, handle in enumerate(handles):
np.ascontiguousarray(joints[:, column]).tofile(handle)
sources.append({"name": source.name, "count": source_count, "selection": source.dataset.manifest()})
if not count:
raise ValueError("No valid training current frames for state 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, 12))
for column, path in enumerate(paths):
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
offset, scale = state_parameters(quantiles[:1], quantiles[1:], minimum[None], maximum[None])
return validate_state_norm({"version": 1, "layout": STATE_LAYOUT, "joint_indices": JOINT_INDICES,
"statistics_scope": "train_valid_current_frames", "minimum_span": 1e-3, "fallback_span": 1e-4,
"count": count, "sources": sources, "q01": quantiles[:1].tolist(), "q99": quantiles[1:].tolist(),
"min": minimum[None].tolist(), "max": maximum[None].tolist(),
"offset": offset.tolist(), "scale": scale.tolist()})
def save_state_norm(directory, record):
validate_state_norm(record)
path = Path(directory) / STATE_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_state_norm(checkpoint):
path = Path(checkpoint).expanduser()
directory = path if path.is_dir() else path.parent
record = json.loads((directory / STATE_NORM_FILENAME).read_text())
return validate_state_norm(record)
def assert_state_norm_matches(policy, record):
validate_state_norm(record)
for name in ("offset", "scale"):
actual = getattr(policy, f"state_{name}").detach().float().cpu()
if not torch.equal(actual, torch.tensor(record[name], dtype=torch.float32)):
raise ValueError(f"Checkpoint state_{name} differs from {STATE_NORM_FILENAME}")
def initialize_state_normalization(policy, dataset, config, accelerator):
from accelerate.utils import broadcast_object_list
result = [None]
if accelerator.is_main_process:
try:
root = Path(config["output_dir"])
root.mkdir(parents=True, exist_ok=True)
record = load_checkpoint_state_norm(config["resume"]) if config.get("resume") else compute_state_norm(dataset, root)
save_state_norm(root, record)
result[0] = {"record": record}
except Exception as exc:
result[0] = {"error": f"{type(exc).__name__}: {exc}"}
broadcast_object_list(result)
if "error" in result[0]:
raise ValueError(result[0]["error"])
record = result[0]["record"]
if config.get("resume"):
assert_state_norm_matches(policy, record)
else:
policy.set_state_stats(record["offset"], record["scale"])
return record