mirror of
https://github.com/huggingface/lerobot.git
synced 2026-07-23 17:56:07 +00:00
e0d455ec9f
If the data files and any camera's video files (or two cameras) don't have the same number of episodes, skip the dataset up front instead of letting the converter raise 'All cams dont have same number of episodes' mid-run.
180 lines
8.2 KiB
Python
180 lines
8.2 KiB
Python
"""Rewrite observation.state / action to degrees in a LOCAL v2.1 SO-arm dataset, then
|
|
regenerate meta/episodes_stats.jsonl (action & state only; other features preserved).
|
|
Run this BEFORE the stock v2.1->v3.0 converter so its stats aggregation stays correct.
|
|
"""
|
|
import json
|
|
from pathlib import Path
|
|
import numpy as np
|
|
import pandas as pd
|
|
|
|
import so_arm_frame
|
|
from classify import classify, load_info, so_joint_count
|
|
|
|
VALUE_COLS = ("observation.state", "action")
|
|
|
|
|
|
def _stack(col_values) -> np.ndarray:
|
|
return np.stack([np.asarray(v, dtype=np.float64) for v in col_values]) # (N, D)
|
|
|
|
|
|
def _set_robot_type(root: Path, robot_type: str) -> None:
|
|
info_path = root / "meta" / "info.json"
|
|
info = json.loads(info_path.read_text())
|
|
info["robot_type"] = robot_type
|
|
info_path.write_text(json.dumps(info, indent=4))
|
|
|
|
|
|
def _rewrite_parquet(root: Path, encoding: str, so_dims: dict) -> None:
|
|
for pq in sorted((root / "data").glob("*/*.parquet")):
|
|
df = pd.read_parquet(pq)
|
|
changed = False
|
|
for col in VALUE_COLS:
|
|
n = so_dims.get(col, 0)
|
|
if col in df.columns and n:
|
|
full = _stack(df[col].values) # (N, D)
|
|
full[:, :n] = so_arm_frame.to_degrees(full[:, :n], encoding, n_joints_per_arm=6)
|
|
df[col] = list(full.astype(np.float32))
|
|
changed = True
|
|
if changed:
|
|
df.to_parquet(pq, index=False)
|
|
|
|
|
|
def _regen_episode_stats(root: Path) -> None:
|
|
stats_path = root / "meta" / "episodes_stats.jsonl"
|
|
orig = {}
|
|
with open(stats_path) as f:
|
|
for line in f:
|
|
e = json.loads(line)
|
|
orig[e["episode_index"]] = e
|
|
for pq in sorted((root / "data").glob("*/*.parquet")):
|
|
df = pd.read_parquet(pq)
|
|
for ep in np.unique(df["episode_index"].values):
|
|
ep = int(ep)
|
|
sub = df[df["episode_index"] == ep]
|
|
entry = orig.get(ep)
|
|
if entry is None:
|
|
continue
|
|
for col in VALUE_COLS:
|
|
if col in sub.columns:
|
|
a = _stack(sub[col].values) # (n, D)
|
|
entry["stats"][col] = {
|
|
"min": a.min(0).tolist(), "max": a.max(0).tolist(),
|
|
"mean": a.mean(0).tolist(), "std": a.std(0).tolist(),
|
|
"count": [int(a.shape[0])],
|
|
}
|
|
with open(stats_path, "w") as f:
|
|
for ep in sorted(orig):
|
|
f.write(json.dumps(orig[ep]) + "\n")
|
|
|
|
|
|
def _read_jsonl(path: Path) -> list[dict]:
|
|
with open(path) as f:
|
|
return [json.loads(line) for line in f if line.strip()]
|
|
|
|
|
|
def _write_jsonl(path: Path, rows: list[dict]) -> None:
|
|
with open(path, "w") as f:
|
|
for r in rows:
|
|
f.write(json.dumps(r) + "\n")
|
|
|
|
|
|
def data_video_episode_mismatch(root) -> str | None:
|
|
"""Return a description when the data files and any camera's video files disagree on the
|
|
episode count (dataset can't be migrated, e.g. 'All cams dont have same number of episodes'),
|
|
else None. Datasets without videos never mismatch here."""
|
|
root = Path(root)
|
|
info = json.loads((root / "meta" / "info.json").read_text())
|
|
counts = {"data": len(list((root / "data").glob("*/episode_*.parquet")))}
|
|
for k, f in info.get("features", {}).items():
|
|
if f.get("dtype") == "video":
|
|
counts[k] = len(list((root / "videos").glob(f"*/{k}/episode_*.mp4")))
|
|
if len(counts) > 1 and len(set(counts.values())) > 1:
|
|
return f"data/video episode counts disagree: {counts}"
|
|
return None
|
|
|
|
|
|
def reconcile_episode_count(root) -> str | None:
|
|
"""When the data files and video files agree on an episode count N but the metadata lists a
|
|
different count, rewrite the metadata (episodes.jsonl, episodes_stats.jsonl, info.json) to N.
|
|
|
|
Only the safe direction is handled: trimming metadata that lists MORE episodes than actually
|
|
exist. If the data itself is non-contiguous, the videos disagree with the data, or the metadata
|
|
lists FEWER episodes than the data (which would require fabricating per-episode stats), nothing
|
|
is changed and the stock converter's mismatch error is left to surface. Returns a note on fix."""
|
|
root = Path(root)
|
|
info = json.loads((root / "meta" / "info.json").read_text())
|
|
|
|
data_idx = sorted(int(p.stem.split("_")[-1]) for p in (root / "data").glob("*/episode_*.parquet"))
|
|
n = len(data_idx)
|
|
if n == 0 or data_idx != list(range(n)):
|
|
return None
|
|
|
|
vkeys = [k for k, f in info.get("features", {}).items() if f.get("dtype") == "video"]
|
|
for k in vkeys:
|
|
if len(list((root / "videos").glob(f"*/{k}/episode_*.mp4"))) != n:
|
|
return None # data and videos disagree -> out of scope for this fix
|
|
|
|
eps_path = root / "meta" / "episodes.jsonl"
|
|
stats_path = root / "meta" / "episodes_stats.jsonl"
|
|
eps, stats = _read_jsonl(eps_path), _read_jsonl(stats_path)
|
|
if len(eps) == n and len(stats) == n:
|
|
return None
|
|
|
|
eps_keep = [e for e in eps if e.get("episode_index", -1) < n]
|
|
stats_keep = [s for s in stats if s.get("episode_index", -1) < n]
|
|
if len(eps_keep) != n or len(stats_keep) != n:
|
|
return None # metadata is missing episodes present in the data -> can't safely fabricate
|
|
|
|
dropped = max(len(eps), len(stats)) - n
|
|
_write_jsonl(eps_path, eps_keep)
|
|
_write_jsonl(stats_path, stats_keep)
|
|
info["total_episodes"] = n
|
|
info["total_frames"] = int(sum(e.get("length", 0) for e in eps_keep))
|
|
if "total_videos" in info:
|
|
info["total_videos"] = n * len(vkeys)
|
|
info["splits"] = {"train": f"0:{n}"}
|
|
(root / "meta" / "info.json").write_text(json.dumps(info, indent=4))
|
|
return f"metadata episode count reconciled to {n} (data & videos agree; dropped {dropped} stale meta entries)"
|
|
|
|
|
|
def fix_dataset_in_place(root) -> dict:
|
|
"""Returns the classification dict augmented with the action taken."""
|
|
root = Path(root)
|
|
cls = classify(root)
|
|
if cls.get("mislabeled_so"):
|
|
# robot_type claims SO but the joints prove otherwise (wrong dim or non-SO names).
|
|
# Relabel to 'unknown' and migrate structurally rather than degrees-converting on a
|
|
# false assumption; the joint values are left exactly as recorded.
|
|
_set_robot_type(root, "unknown")
|
|
return {**cls, "robot_type": "unknown", "converted": False,
|
|
"action": f"structural v2.1->v3.0 only; robot_type relabeled '{cls.get('robot_type')}'"
|
|
"->'unknown' (joints don't match a 6-DOF SO arm), joint values left unchanged"}
|
|
enc = cls.get("encoding")
|
|
if not cls.get("is_so") or enc in ("radians", "unknown", "non_so"):
|
|
reason = {
|
|
"non_so": "not an SO-100/101 dataset",
|
|
"radians": "SO-arm joints already in radians",
|
|
"unknown": "SO-arm but joint encoding could not be determined",
|
|
}.get(enc, "no joint conversion applicable")
|
|
return {**cls, "converted": False,
|
|
"action": f"structural v2.1->v3.0 only ({reason}); joint values left unchanged"}
|
|
if enc == "normalized" and not so_arm_frame.CANON_IS_CALIBRATED:
|
|
# Without per-robot calibration the un-normalization is an identity (placeholder
|
|
# spans == 100), so rewriting is pointless. Keep the normalized values as-is and let
|
|
# the dataset card flag them APPROXIMATE instead.
|
|
return {**cls, "converted": False,
|
|
"action": "structural v2.1->v3.0 only; joint values kept in normalized units "
|
|
"(-100..100 / 0..100), NOT converted to degrees (uncalibrated -> APPROXIMATE)"}
|
|
# drop stray files that would otherwise be uploaded
|
|
for junk in (root / "meta").glob("info.json.bak"):
|
|
junk.unlink()
|
|
info = load_info(root)
|
|
so_dims = {c: so_joint_count(info, c) for c in VALUE_COLS}
|
|
_rewrite_parquet(root, enc, so_dims)
|
|
_regen_episode_stats(root)
|
|
full_dims = {c: (info.get("features", {}).get(c, {}).get("shape") or [0])[0] for c in VALUE_COLS}
|
|
partial = any(0 < so_dims[c] < full_dims[c] for c in VALUE_COLS)
|
|
tail = " (leading SO joints only; trailing non-joint columns left unchanged)" if partial else ""
|
|
return {**cls, "converted": True,
|
|
"action": f"structural v2.1->v3.0 + joint values converted ({enc} -> degrees){tail}"}
|