lfz_lehome_v2 / scripts /utils /dataset_processing.py
taikonauts's picture
Upload folder using huggingface_hub
1cc903b verified
Raw
History Blame Contribute Delete
17.4 kB
"""Dataset processing utilities for augmenting and merging LeRobot datasets."""
from pathlib import Path
from typing import Optional, List, TYPE_CHECKING
import json
import shutil
import traceback
import numpy as np
import pyarrow as pa
import pyarrow.parquet as pq
from lerobot.datasets.dataset_tools import merge_datasets as lerobot_merge_datasets
from lerobot.datasets.lerobot_dataset import LeRobotDataset
from lehome.utils import RobotKinematics, compute_ee_pose_single_arm
from lehome.utils.logger import get_logger
if TYPE_CHECKING:
pass
logger = get_logger(__name__)
def compute_ee_pose_batch(
solver: RobotKinematics,
joint_batch: np.ndarray,
state_unit: str,
is_bimanual: bool,
) -> np.ndarray:
"""Compute end-effector poses for a batch of joint configurations.
Returns:
- Single-arm: shape (N, 8) - [x, y, z, qx, qy, qz, qw, gripper]
- Dual-arm: shape (N, 16) - [left_8D, right_8D]
"""
poses = []
for idx, joints in enumerate(joint_batch):
joints = np.asarray(joints, dtype=np.float32)
try:
if is_bimanual:
left_joints = joints[:6]
right_joints = joints[6:12]
left_pose = compute_ee_pose_single_arm(solver, left_joints, state_unit)
right_pose = compute_ee_pose_single_arm(
solver, right_joints, state_unit
)
poses.append(np.concatenate([left_pose, right_pose], axis=0))
else:
poses.append(compute_ee_pose_single_arm(solver, joints, state_unit))
except Exception as e:
raise RuntimeError(
f"Failed to compute EE pose for frame {idx} (joints: {joints}): {e}"
) from e
return np.stack(poses, axis=0)
def add_ee_pose_to_parquet(
parquet_path: Path,
solver: RobotKinematics,
state_unit: str,
is_bimanual: bool,
output_path: Path,
) -> None:
"""Add end-effector pose columns to a Parquet file."""
table = pq.read_table(parquet_path)
if "observation.state" not in table.column_names:
raise KeyError(f"'observation.state' not found in {parquet_path}")
if "action" not in table.column_names:
raise KeyError(f"'action' not found in {parquet_path}")
pose_dim = 16 if is_bimanual else 8
obs_joint_batch = np.stack(table["observation.state"].to_pylist(), axis=0)
obs_ee_pose = compute_ee_pose_batch(
solver, obs_joint_batch, state_unit, is_bimanual
)
obs_ee_col = pa.array(obs_ee_pose.tolist(), type=pa.list_(pa.float32(), pose_dim))
action_joint_batch = np.stack(table["action"].to_pylist(), axis=0)
action_ee_pose = compute_ee_pose_batch(
solver, action_joint_batch, state_unit, is_bimanual
)
action_ee_col = pa.array(
action_ee_pose.tolist(), type=pa.list_(pa.float32(), pose_dim)
)
new_table = table.append_column("observation.ee_pose", obs_ee_col)
new_table = new_table.append_column("action.ee_pose", action_ee_col)
output_path.parent.mkdir(parents=True, exist_ok=True)
pq.write_table(new_table, output_path)
def update_info_json(meta_path: Path, is_bimanual: bool, overwrite: bool) -> None:
"""Update dataset metadata (info.json) to include ee_pose feature definitions."""
info_path = meta_path / "info.json"
with info_path.open("r") as f:
info = json.load(f)
feats = info.get("features", {})
if ("observation.ee_pose" in feats or "action.ee_pose" in feats) and not overwrite:
raise RuntimeError(
"ee_pose features already exist; use --overwrite to replace."
)
if is_bimanual:
ee_pose_feature = {
"dtype": "float32",
"shape": [16],
"names": [
"left_x",
"left_y",
"left_z",
"left_qx",
"left_qy",
"left_qz",
"left_qw",
"left_gripper",
"right_x",
"right_y",
"right_z",
"right_qx",
"right_qy",
"right_qz",
"right_qw",
"right_gripper",
],
}
else:
ee_pose_feature = {
"dtype": "float32",
"shape": [8],
"names": ["x", "y", "z", "qx", "qy", "qz", "qw", "gripper"],
}
feats["observation.ee_pose"] = ee_pose_feature
feats["action.ee_pose"] = ee_pose_feature
info["features"] = feats
with info_path.open("w") as f:
json.dump(info, f, indent=4)
def augment_ee_pose(
dataset_root: Path,
urdf_path: Path,
state_unit: str = "rad",
output_root: Optional[Path] = None,
overwrite: bool = False,
) -> None:
"""Add end-effector pose to existing datasets."""
dataset_root = dataset_root.resolve()
urdf_path = urdf_path.resolve()
output_root = output_root.resolve() if output_root else dataset_root
if not dataset_root.exists():
raise FileNotFoundError(f"Dataset root not found: {dataset_root}")
if not urdf_path.exists():
raise FileNotFoundError(f"URDF path not found: {urdf_path}")
meta_dir = dataset_root / "meta"
info_path = meta_dir / "info.json"
with info_path.open("r") as f:
info = json.load(f)
joint_names = info["features"]["observation.state"]["names"]
num_joints = len(joint_names)
if num_joints == 6:
is_bimanual = False
solver_joint_names = joint_names[:5]
print("✓ Detected single-arm dataset (6 DoF)")
elif num_joints == 12:
is_bimanual = True
solver_joint_names = [n.replace("left_", "") for n in joint_names[:5]]
print("✓ Detected dual-arm dataset (12 DoF)")
else:
raise ValueError(
f"Unsupported number of joints: {num_joints}. "
f"Only 6 (single-arm) or 12 (dual-arm) are supported."
)
solver = RobotKinematics(
str(urdf_path),
target_frame_name="gripper_frame_link",
joint_names=solver_joint_names,
)
data_root = dataset_root / "data"
parquet_files = sorted(data_root.glob("chunk-*/file-*.parquet"))
if not parquet_files:
raise FileNotFoundError(f"No parquet files found under {data_root}")
total_files = len(parquet_files)
print(f"📦 Processing {total_files} parquet file(s)...")
for idx, src in enumerate(parquet_files, 1):
rel = src.relative_to(dataset_root)
dst = output_root / rel
if dst.exists() and not overwrite:
raise FileExistsError(
f"{dst} exists; use --overwrite or set --output_root to new dir."
)
print(f" [{idx}/{total_files}] {src.name}")
try:
add_ee_pose_to_parquet(src, solver, state_unit, is_bimanual, dst)
except Exception as e:
raise RuntimeError(f"Failed to process {src}: {e}") from e
if output_root != dataset_root:
print("📋 Copying meta, videos, and images...")
for sub in ["meta", "videos", "images"]:
src_dir = dataset_root / sub
dst_dir = output_root / sub
if src_dir.exists():
if dst_dir.exists() and not overwrite:
raise FileExistsError(f"{dst_dir} exists; use --overwrite.")
shutil.copytree(src_dir, dst_dir, dirs_exist_ok=True)
update_info_json(output_root / "meta", is_bimanual, overwrite=overwrite)
pose_dim = 16 if is_bimanual else 8
print(f"✅ Done! Added ee_pose features ({pose_dim}D) to dataset.")
def _fix_depth_data_format(dataset_root: Path) -> None:
"""Ensure observation.top_depth has a stable Arrow schema for merging."""
dataset_root = dataset_root.resolve()
data_root = dataset_root / "data"
parquet_files = sorted(data_root.glob("chunk-*/file-*.parquet"))
if not parquet_files:
return
try:
first_table = pq.read_table(parquet_files[0])
except Exception as e:
logger.warning(f"Failed to read parquet file {parquet_files[0]}: {e}")
return
if "observation.top_depth" not in first_table.column_names:
return
logger.info(
f"Found observation.top_depth in {dataset_root.name}, "
f"normalizing depth column schema in {len(parquet_files)} parquet file(s)..."
)
for pf in parquet_files:
try:
table = pq.read_table(pf)
if "observation.top_depth" not in table.column_names:
continue
depth_col = table["observation.top_depth"]
depth_list = depth_col.to_pylist()
fixed_list = []
for item in depth_list:
if item is None:
fixed_list.append(None)
continue
if isinstance(item, np.ndarray):
item = item.tolist()
# Item should be a 2D list: H x W
if isinstance(item, list):
new_rows = []
for row in item:
if isinstance(row, np.ndarray):
new_rows.append(row.astype(np.float32).tolist())
elif isinstance(row, list):
new_rows.append([float(v) for v in row])
else:
# Unexpected format, convert to float list
new_rows.append([float(row)])
fixed_list.append(new_rows)
else:
# Fallback: scalar/1D, convert to single-row list
fixed_list.append([[float(item)]])
# Infer H, W from first non-None item
height = width = None
for item in fixed_list:
if item is not None and isinstance(item, list) and len(item) > 0:
height = len(item)
width = len(item[0]) if isinstance(item[0], list) else None
break
if height is None or width is None:
logger.warning(f"Skip depth normalization for {pf}: cannot infer shape.")
continue
# Auto-detect dtype from first non-None item
sample_value = None
for item in fixed_list:
if item is not None and isinstance(item, list) and len(item) > 0:
if isinstance(item[0], list) and len(item[0]) > 0:
sample_value = item[0][0]
break
# Determine Arrow type based on sample value
if sample_value is not None and isinstance(sample_value, (int, np.integer)):
depth_type = pa.list_(pa.list_(pa.uint16(), width), height)
else:
depth_type = pa.list_(pa.list_(pa.float32(), width), height)
new_depth_array = pa.array(fixed_list, type=depth_type)
col_idx = table.column_names.index("observation.top_depth")
table = table.remove_column(col_idx)
table = table.add_column(col_idx, "observation.top_depth", new_depth_array)
pq.write_table(table, pf)
except Exception as e:
logger.warning(f"Failed to normalize depth column in {pf}: {e}")
continue
logger.info(f"Depth column normalization completed for {dataset_root.name}.")
def merge_datasets(
source_roots: List[Path],
output_root: Path,
output_repo_id: str = "merged_dataset",
merge_custom_meta: bool = True,
) -> None:
"""Merge multiple LeRobot datasets including custom meta files.
Args:
source_roots: List of source dataset root directories
output_root: Output dataset root directory
output_repo_id: Repository ID for the merged dataset
merge_custom_meta: Whether to merge custom meta files (garment_info.json)
"""
# Validate source datasets
for source_root in source_roots:
if not source_root.exists():
raise ValueError(f"Source dataset not found: {source_root}")
if not (source_root / "meta").exists():
raise ValueError(f"Meta directory not found in {source_root}")
logger.info(f"Merging {len(source_roots)} datasets:")
for i, root in enumerate(source_roots, 1):
logger.info(f" {i}. {root}")
logger.info(f"Output: {output_root}")
# Normalize depth column schema if needed (to avoid ArrowTypeError)
for source_root in source_roots:
try:
_fix_depth_data_format(source_root)
except Exception as e:
logger.warning(f"Depth format normalization failed for {source_root}: {e}")
# Load all source datasets
datasets = []
for source_root in source_roots:
repo_id = source_root.name
try:
dataset = LeRobotDataset(repo_id=repo_id, root=source_root)
datasets.append(dataset)
logger.info(
f"Loaded dataset: {repo_id} ({dataset.meta.total_episodes} episodes) from {dataset.root}"
)
except Exception as e:
logger.error(f"Failed to load dataset {repo_id}: {e}")
logger.error(f" Source root: {source_root}")
traceback.print_exc()
raise
# Merge datasets
logger.info("Starting dataset merge...")
merged_dataset = lerobot_merge_datasets(
datasets=datasets,
output_repo_id=output_repo_id,
output_dir=output_root,
)
logger.info(f"Merged dataset created:")
logger.info(f" Total episodes: {merged_dataset.meta.total_episodes}")
logger.info(f" Total frames: {merged_dataset.meta.total_frames}")
logger.info(f" Location: {output_root}")
# Merge custom meta files
if merge_custom_meta:
logger.info("Merging custom meta files...")
merge_garment_info(source_roots, output_root)
logger.info("Custom meta files merged successfully")
logger.info("Dataset merge completed!")
def merge_garment_info(source_roots: List[Path], output_root: Path) -> int:
"""Merge garment_info.json files from multiple datasets.
Format:
{
"Top_Long_Unseen_0": {
"0": {"object_initial_pose": [...], "scale": [...]},
"1": {"object_initial_pose": [...], "scale": [...]}
}
}
Args:
source_roots: List of source dataset root directories
output_root: Output dataset root directory
Returns:
Total number of episodes merged
"""
output_path = output_root / "meta" / "garment_info.json"
output_path.parent.mkdir(parents=True, exist_ok=True)
merged_data = {}
episode_offset = 0
total_merged = 0
for source_root in source_roots:
source_path = source_root / "meta" / "garment_info.json"
if not source_path.exists():
logger.warning(f"garment_info.json not found in {source_root}, skipping...")
info_path = source_root / "meta" / "info.json"
if info_path.exists():
with open(info_path, "r") as f:
episode_offset += json.load(f).get("total_episodes", 0)
continue
logger.info(f"Merging garment_info.json from {source_root}")
count = 0
try:
with open(source_path, "r", encoding="utf-8") as f:
source_data = json.load(f)
for garment_name, episodes in source_data.items():
if garment_name not in merged_data:
merged_data[garment_name] = {}
for episode_key, episode_data in episodes.items():
try:
old_idx = int(episode_key)
new_key = str(old_idx + episode_offset)
merged_data[garment_name][new_key] = episode_data.copy()
count += 1
except (ValueError, TypeError) as e:
logger.warning(
f"Invalid episode key '{episode_key}' in {source_path}: {e}"
)
continue
except (json.JSONDecodeError, FileNotFoundError) as e:
logger.warning(f"Failed to parse {source_path}: {e}")
continue
total_merged += count
logger.info(f" Merged {count} episodes from {source_root}")
# Update episode offset for next dataset
info_path = source_root / "meta" / "info.json"
if info_path.exists():
with open(info_path, "r") as f:
episode_offset += json.load(f).get("total_episodes", count)
else:
episode_offset += count
# Sort by garment_name and episode indices
sorted_data = {}
for garment_name in sorted(merged_data.keys()):
episodes = merged_data[garment_name]
sorted_episodes = {
str(k): episodes[str(k)]
for k in sorted(int(key) for key in episodes.keys())
}
sorted_data[garment_name] = sorted_episodes
with open(output_path, "w", encoding="utf-8") as f:
json.dump(sorted_data, f, indent=2, ensure_ascii=False)
logger.info(
f"Total merged {total_merged} episodes from {len(sorted_data)} garments to {output_path}"
)
return total_merged