Add files using upload-large-folder tool
Browse filesThis view is limited to 50 files because it contains too many changes. See raw diff
- GR00T-WholeBodyControl/gear_sonic/__pycache__/__init__.cpython-310.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/__pycache__/__init__.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/__pycache__/__init__.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/__pycache__/eval_agent_trl.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/__pycache__/train_agent_trl.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/__pycache__/train_agent_trl.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/__pycache__/version.cpython-310.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/__pycache__/version.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/camera/sensor.py +33 -0
- GR00T-WholeBodyControl/gear_sonic/camera/sensor_server.py +243 -0
- GR00T-WholeBodyControl/gear_sonic/data/exporter.py +472 -0
- GR00T-WholeBodyControl/gear_sonic/data/features_sonic_vla.py +410 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/left_ankle_pitch_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/left_elbow_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/left_hand_thumb_0_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/right_ankle_pitch_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/right_elbow_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/right_hand_thumb_0_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/right_wrist_pitch_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/torso_constraint_L_rod_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/torso_constraint_R_rod_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/waist_roll_link.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/waist_roll_link_rev_1_0.STL +0 -0
- GR00T-WholeBodyControl/gear_sonic/data/video_writer.py +99 -0
- GR00T-WholeBodyControl/gear_sonic/data_process/convert_soma_csv_to_motion_lib.py +584 -0
- GR00T-WholeBodyControl/gear_sonic/data_process/extract_soma_joints_from_bvh.py +394 -0
- GR00T-WholeBodyControl/gear_sonic/data_process/filter_and_copy_bones_data.py +294 -0
- GR00T-WholeBodyControl/gear_sonic/data_process/split_pkl_files.py +193 -0
- GR00T-WholeBodyControl/gear_sonic/envs/__init__.py +0 -0
- GR00T-WholeBodyControl/gear_sonic/isaac_utils/__init__.py +0 -0
- GR00T-WholeBodyControl/gear_sonic/isaac_utils/maths.py +52 -0
- GR00T-WholeBodyControl/gear_sonic/isaac_utils/rotations.py +787 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__init__.py +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/__init__.cpython-310.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/__init__.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/__init__.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/average_meters.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/average_meters.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/batch_normalizer.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/batch_normalizer.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/common.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/common.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/config_utils.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/config_utils.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/logging.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/logging.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/obs_utils.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/obs_utils.cpython-312.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/running_mean_std.cpython-311.pyc +0 -0
- GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/running_mean_std.cpython-312.pyc +0 -0
GR00T-WholeBodyControl/gear_sonic/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (153 Bytes). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (169 Bytes). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (157 Bytes). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/__pycache__/eval_agent_trl.cpython-312.pyc
ADDED
|
Binary file (34.8 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/__pycache__/train_agent_trl.cpython-311.pyc
ADDED
|
Binary file (23.8 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/__pycache__/train_agent_trl.cpython-312.pyc
ADDED
|
Binary file (22.9 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/__pycache__/version.cpython-310.pyc
ADDED
|
Binary file (365 Bytes). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/__pycache__/version.cpython-312.pyc
ADDED
|
Binary file (466 Bytes). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/camera/sensor.py
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Base sensor abstract class.
|
| 2 |
+
|
| 3 |
+
The ``gymnasium`` dependency is lazy-imported so the camera server can
|
| 4 |
+
run without it.
|
| 5 |
+
"""
|
| 6 |
+
|
| 7 |
+
from abc import abstractmethod
|
| 8 |
+
from typing import Any
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class Sensor:
|
| 12 |
+
"""Base class for camera / sensor implementations.
|
| 13 |
+
|
| 14 |
+
Concrete drivers (OAK, RealSense, ZED, USB, …) inherit from this and
|
| 15 |
+
implement at least :meth:`read` and :meth:`serialize`.
|
| 16 |
+
"""
|
| 17 |
+
|
| 18 |
+
def read(self, **kwargs) -> Any:
|
| 19 |
+
"""Read the current sensor value (e.g. a dict of images)."""
|
| 20 |
+
|
| 21 |
+
def observation_space(self):
|
| 22 |
+
"""Return a ``gymnasium.Space`` describing the observation.
|
| 23 |
+
|
| 24 |
+
Only used during init to report camera capabilities to the
|
| 25 |
+
composed-camera orchestrator; not required for data collection.
|
| 26 |
+
"""
|
| 27 |
+
|
| 28 |
+
@abstractmethod
|
| 29 |
+
def serialize(self, data: dict[str, Any]) -> dict[str, Any]:
|
| 30 |
+
"""Serialize the sensor reading for ZMQ transmission."""
|
| 31 |
+
|
| 32 |
+
def close(self):
|
| 33 |
+
"""Release hardware resources."""
|
GR00T-WholeBodyControl/gear_sonic/camera/sensor_server.py
ADDED
|
@@ -0,0 +1,243 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""ZMQ PUB/SUB transport and image serialisation for the camera server."""
|
| 2 |
+
|
| 3 |
+
import base64
|
| 4 |
+
from dataclasses import dataclass, field
|
| 5 |
+
from enum import Enum
|
| 6 |
+
from typing import Any
|
| 7 |
+
|
| 8 |
+
import cv2
|
| 9 |
+
import msgpack
|
| 10 |
+
import msgpack_numpy as m
|
| 11 |
+
import numpy as np
|
| 12 |
+
import zmq
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
# =============================================================================
|
| 16 |
+
# Pose Message Schema
|
| 17 |
+
# =============================================================================
|
| 18 |
+
@dataclass
|
| 19 |
+
class PoseData:
|
| 20 |
+
"""Single pose data point with quaternion orientation and translation."""
|
| 21 |
+
|
| 22 |
+
qx: float = 0.0
|
| 23 |
+
qy: float = 0.0
|
| 24 |
+
qz: float = 0.0
|
| 25 |
+
qw: float = 1.0
|
| 26 |
+
tx: float = 0.0
|
| 27 |
+
ty: float = 0.0
|
| 28 |
+
tz: float = 0.0
|
| 29 |
+
|
| 30 |
+
def to_dict(self) -> dict[str, float]:
|
| 31 |
+
return {
|
| 32 |
+
"qx": self.qx,
|
| 33 |
+
"qy": self.qy,
|
| 34 |
+
"qz": self.qz,
|
| 35 |
+
"qw": self.qw,
|
| 36 |
+
"tx": self.tx,
|
| 37 |
+
"ty": self.ty,
|
| 38 |
+
"tz": self.tz,
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
@staticmethod
|
| 42 |
+
def from_dict(data: dict[str, float]) -> "PoseData":
|
| 43 |
+
return PoseData(
|
| 44 |
+
qx=data.get("qx", 0.0),
|
| 45 |
+
qy=data.get("qy", 0.0),
|
| 46 |
+
qz=data.get("qz", 0.0),
|
| 47 |
+
qw=data.get("qw", 1.0),
|
| 48 |
+
tx=data.get("tx", 0.0),
|
| 49 |
+
ty=data.get("ty", 0.0),
|
| 50 |
+
tz=data.get("tz", 0.0),
|
| 51 |
+
)
|
| 52 |
+
|
| 53 |
+
def to_array(self) -> np.ndarray:
|
| 54 |
+
return np.array([self.qx, self.qy, self.qz, self.qw, self.tx, self.ty, self.tz])
|
| 55 |
+
|
| 56 |
+
@staticmethod
|
| 57 |
+
def from_array(arr: np.ndarray) -> "PoseData":
|
| 58 |
+
return PoseData(
|
| 59 |
+
qx=float(arr[0]),
|
| 60 |
+
qy=float(arr[1]),
|
| 61 |
+
qz=float(arr[2]),
|
| 62 |
+
qw=float(arr[3]),
|
| 63 |
+
tx=float(arr[4]),
|
| 64 |
+
ty=float(arr[5]),
|
| 65 |
+
tz=float(arr[6]),
|
| 66 |
+
)
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
@dataclass
|
| 70 |
+
class PoseMessageSchema:
|
| 71 |
+
"""Standardized message schema for pose / positional data."""
|
| 72 |
+
|
| 73 |
+
timestamp: float = 0.0
|
| 74 |
+
device_id: str = "iphone"
|
| 75 |
+
pose: PoseData = field(default_factory=PoseData)
|
| 76 |
+
|
| 77 |
+
def serialize(self) -> bytes:
|
| 78 |
+
data = {
|
| 79 |
+
"timestamp": self.timestamp,
|
| 80 |
+
"device_id": self.device_id,
|
| 81 |
+
"pose": self.pose.to_dict(),
|
| 82 |
+
}
|
| 83 |
+
return msgpack.packb(data, use_bin_type=True)
|
| 84 |
+
|
| 85 |
+
@staticmethod
|
| 86 |
+
def deserialize(packed_data: bytes) -> "PoseMessageSchema":
|
| 87 |
+
data = msgpack.unpackb(packed_data, object_hook=m.decode)
|
| 88 |
+
return PoseMessageSchema(
|
| 89 |
+
timestamp=data.get("timestamp", 0.0),
|
| 90 |
+
device_id=data.get("device_id", "iphone"),
|
| 91 |
+
pose=PoseData.from_dict(data.get("pose", {})),
|
| 92 |
+
)
|
| 93 |
+
|
| 94 |
+
def asdict(self) -> dict[str, Any]:
|
| 95 |
+
return {
|
| 96 |
+
"timestamp": self.timestamp,
|
| 97 |
+
"device_id": self.device_id,
|
| 98 |
+
"pose": self.pose.to_dict(),
|
| 99 |
+
}
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
# =============================================================================
|
| 103 |
+
# Image Message Schema
|
| 104 |
+
# =============================================================================
|
| 105 |
+
@dataclass
|
| 106 |
+
class ImageMessageSchema:
|
| 107 |
+
"""Standardized message schema for camera images.
|
| 108 |
+
|
| 109 |
+
Handles two encodings on the wire:
|
| 110 |
+
|
| 111 |
+
* **str** – legacy base64-encoded JPEG.
|
| 112 |
+
* **bytes** – raw JPEG from on-device MJPEG encoder (e.g. OAK).
|
| 113 |
+
"""
|
| 114 |
+
|
| 115 |
+
timestamps: dict[str, float]
|
| 116 |
+
images: dict[str, np.ndarray]
|
| 117 |
+
|
| 118 |
+
def serialize(self) -> dict[str, Any]:
|
| 119 |
+
serialized_msg: dict[str, Any] = {"timestamps": self.timestamps, "images": {}}
|
| 120 |
+
for key, image in self.images.items():
|
| 121 |
+
if isinstance(image, bytes | bytearray):
|
| 122 |
+
serialized_msg["images"][key] = image
|
| 123 |
+
else:
|
| 124 |
+
serialized_msg["images"][key] = ImageUtils.encode_image(image)
|
| 125 |
+
return serialized_msg
|
| 126 |
+
|
| 127 |
+
@staticmethod
|
| 128 |
+
def deserialize(data: dict[str, Any]) -> "ImageMessageSchema":
|
| 129 |
+
timestamps = data.get("timestamps", {})
|
| 130 |
+
images = {}
|
| 131 |
+
for key, value in data.get("images", {}).items():
|
| 132 |
+
if isinstance(value, bytes | bytearray):
|
| 133 |
+
mat = cv2.imdecode(np.frombuffer(value, dtype=np.uint8), cv2.IMREAD_COLOR)
|
| 134 |
+
images[key] = mat[..., ::-1] # BGR -> RGB
|
| 135 |
+
elif isinstance(value, str):
|
| 136 |
+
images[key] = ImageUtils.decode_image(value)
|
| 137 |
+
elif isinstance(value, np.ndarray):
|
| 138 |
+
images[key] = value
|
| 139 |
+
elif isinstance(value, dict) and b"nd" in value:
|
| 140 |
+
images[key] = m.decode(value)
|
| 141 |
+
else:
|
| 142 |
+
images[key] = value
|
| 143 |
+
return ImageMessageSchema(timestamps=timestamps, images=images)
|
| 144 |
+
|
| 145 |
+
def asdict(self) -> dict[str, Any]:
|
| 146 |
+
return {"timestamps": self.timestamps, "images": self.images}
|
| 147 |
+
|
| 148 |
+
|
| 149 |
+
# =============================================================================
|
| 150 |
+
# ZMQ Server / Client
|
| 151 |
+
# =============================================================================
|
| 152 |
+
class SensorServer:
|
| 153 |
+
"""ZMQ PUB server that streams msgpack-encoded sensor payloads."""
|
| 154 |
+
|
| 155 |
+
def start_server(self, port: int):
|
| 156 |
+
self.context = zmq.Context()
|
| 157 |
+
self.socket = self.context.socket(zmq.PUB)
|
| 158 |
+
self.socket.setsockopt(zmq.SNDHWM, 20)
|
| 159 |
+
self.socket.setsockopt(zmq.LINGER, 0)
|
| 160 |
+
self.socket.bind(f"tcp://*:{port}")
|
| 161 |
+
print(f"Sensor server running at tcp://*:{port}")
|
| 162 |
+
|
| 163 |
+
self.message_sent = 0
|
| 164 |
+
self.message_dropped = 0
|
| 165 |
+
|
| 166 |
+
def stop_server(self):
|
| 167 |
+
self.socket.close()
|
| 168 |
+
self.context.term()
|
| 169 |
+
|
| 170 |
+
def send_message(self, data: dict[str, Any]):
|
| 171 |
+
try:
|
| 172 |
+
packed = msgpack.packb(data, use_bin_type=True)
|
| 173 |
+
self.socket.send(packed, flags=zmq.NOBLOCK)
|
| 174 |
+
except zmq.Again:
|
| 175 |
+
self.message_dropped += 1
|
| 176 |
+
print(f"[Warning] message dropped: {self.message_dropped}")
|
| 177 |
+
self.message_sent += 1
|
| 178 |
+
|
| 179 |
+
if self.message_sent % 100 == 0:
|
| 180 |
+
print(
|
| 181 |
+
f"[Sensor server] Message sent: {self.message_sent}, "
|
| 182 |
+
f"message dropped: {self.message_dropped}"
|
| 183 |
+
)
|
| 184 |
+
|
| 185 |
+
|
| 186 |
+
class SensorClient:
|
| 187 |
+
"""ZMQ SUB client that receives msgpack-encoded sensor payloads."""
|
| 188 |
+
|
| 189 |
+
def start_client(self, server_ip: str, port: int):
|
| 190 |
+
self.context = zmq.Context()
|
| 191 |
+
self.socket = self.context.socket(zmq.SUB)
|
| 192 |
+
self.socket.setsockopt_string(zmq.SUBSCRIBE, "")
|
| 193 |
+
self.socket.setsockopt(zmq.CONFLATE, True)
|
| 194 |
+
self.socket.setsockopt(zmq.RCVHWM, 3)
|
| 195 |
+
self.socket.connect(f"tcp://{server_ip}:{port}")
|
| 196 |
+
|
| 197 |
+
def stop_client(self):
|
| 198 |
+
self.socket.close()
|
| 199 |
+
self.context.term()
|
| 200 |
+
|
| 201 |
+
def receive_message(self):
|
| 202 |
+
packed = self.socket.recv()
|
| 203 |
+
return msgpack.unpackb(packed, object_hook=m.decode)
|
| 204 |
+
|
| 205 |
+
def receive_message_nonblocking(self, timeout_ms: int = 0):
|
| 206 |
+
if self.socket.poll(timeout_ms):
|
| 207 |
+
packed = self.socket.recv()
|
| 208 |
+
return msgpack.unpackb(packed, object_hook=m.decode)
|
| 209 |
+
return None
|
| 210 |
+
|
| 211 |
+
|
| 212 |
+
# =============================================================================
|
| 213 |
+
# Helpers
|
| 214 |
+
# =============================================================================
|
| 215 |
+
class CameraMountPosition(Enum):
|
| 216 |
+
EGO_VIEW = "ego_view"
|
| 217 |
+
HEAD = "head"
|
| 218 |
+
LEFT_WRIST = "left_wrist"
|
| 219 |
+
RIGHT_WRIST = "right_wrist"
|
| 220 |
+
|
| 221 |
+
|
| 222 |
+
class ImageUtils:
|
| 223 |
+
@staticmethod
|
| 224 |
+
def encode_image(image: np.ndarray) -> str:
|
| 225 |
+
_, color_buffer = cv2.imencode(".jpg", image, [int(cv2.IMWRITE_JPEG_QUALITY), 80])
|
| 226 |
+
return base64.b64encode(color_buffer).decode("utf-8")
|
| 227 |
+
|
| 228 |
+
@staticmethod
|
| 229 |
+
def encode_depth_image(image: np.ndarray) -> str:
|
| 230 |
+
depth_compressed = cv2.imencode(".png", image)[1].tobytes()
|
| 231 |
+
return base64.b64encode(depth_compressed).decode("utf-8")
|
| 232 |
+
|
| 233 |
+
@staticmethod
|
| 234 |
+
def decode_image(image: str) -> np.ndarray:
|
| 235 |
+
color_data = base64.b64decode(image)
|
| 236 |
+
color_array = np.frombuffer(color_data, dtype=np.uint8)
|
| 237 |
+
return cv2.imdecode(color_array, cv2.IMREAD_COLOR)
|
| 238 |
+
|
| 239 |
+
@staticmethod
|
| 240 |
+
def decode_depth_image(image: str) -> np.ndarray:
|
| 241 |
+
depth_data = base64.b64decode(image)
|
| 242 |
+
depth_array = np.frombuffer(depth_data, dtype=np.uint8)
|
| 243 |
+
return cv2.imdecode(depth_array, cv2.IMREAD_UNCHANGED)
|
GR00T-WholeBodyControl/gear_sonic/data/exporter.py
ADDED
|
@@ -0,0 +1,472 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Gr00t data exporter for LeRobot-format datasets.
|
| 3 |
+
"""
|
| 4 |
+
|
| 5 |
+
import copy
|
| 6 |
+
from dataclasses import asdict, dataclass
|
| 7 |
+
from functools import partial
|
| 8 |
+
import json
|
| 9 |
+
import os
|
| 10 |
+
from pathlib import Path
|
| 11 |
+
import shutil
|
| 12 |
+
from typing import Any, Optional
|
| 13 |
+
|
| 14 |
+
import datasets
|
| 15 |
+
from datasets import load_dataset
|
| 16 |
+
from datasets.utils import disable_progress_bars
|
| 17 |
+
from huggingface_hub.errors import RepositoryNotFoundError
|
| 18 |
+
from lerobot.common.datasets.lerobot_dataset import (
|
| 19 |
+
LeRobotDataset,
|
| 20 |
+
LeRobotDatasetMetadata,
|
| 21 |
+
compute_episode_stats,
|
| 22 |
+
)
|
| 23 |
+
from lerobot.common.datasets.utils import (
|
| 24 |
+
check_timestamps_sync,
|
| 25 |
+
get_episode_data_index,
|
| 26 |
+
validate_episode_buffer,
|
| 27 |
+
validate_frame,
|
| 28 |
+
)
|
| 29 |
+
import numpy as np
|
| 30 |
+
from PIL import Image as PILImage
|
| 31 |
+
import torch
|
| 32 |
+
from torchvision import transforms
|
| 33 |
+
|
| 34 |
+
from gear_sonic.data.video_writer import VideoWriter
|
| 35 |
+
|
| 36 |
+
disable_progress_bars()
|
| 37 |
+
|
| 38 |
+
|
| 39 |
+
# ---------------------------------------------------------------------------
|
| 40 |
+
# ArgsConfig (inlined from decoupled_wbc.control.main.config_template)
|
| 41 |
+
# ---------------------------------------------------------------------------
|
| 42 |
+
|
| 43 |
+
|
| 44 |
+
@dataclass
|
| 45 |
+
class ArgsConfig:
|
| 46 |
+
"""Minimal config dataclass for script_config serialization."""
|
| 47 |
+
|
| 48 |
+
def update(
|
| 49 |
+
self,
|
| 50 |
+
config_dict: dict,
|
| 51 |
+
strict: bool = False,
|
| 52 |
+
skip_keys: list[str] = [],
|
| 53 |
+
allowed_keys: list[str] | None = None,
|
| 54 |
+
):
|
| 55 |
+
for k, v in config_dict.items():
|
| 56 |
+
if k in skip_keys:
|
| 57 |
+
continue
|
| 58 |
+
if allowed_keys is not None and k not in allowed_keys:
|
| 59 |
+
continue
|
| 60 |
+
if strict and not hasattr(self, k):
|
| 61 |
+
raise ValueError(f"Config {k} not found in {self.__class__.__name__}")
|
| 62 |
+
if not strict and not hasattr(self, k):
|
| 63 |
+
continue
|
| 64 |
+
setattr(self, k, v)
|
| 65 |
+
|
| 66 |
+
@classmethod
|
| 67 |
+
def from_dict(
|
| 68 |
+
cls,
|
| 69 |
+
config_dict: dict,
|
| 70 |
+
strict: bool = False,
|
| 71 |
+
skip_keys: list[str] = [],
|
| 72 |
+
allowed_keys: list[str] | None = None,
|
| 73 |
+
):
|
| 74 |
+
instance = cls()
|
| 75 |
+
instance.update(
|
| 76 |
+
config_dict=config_dict, strict=strict, skip_keys=skip_keys, allowed_keys=allowed_keys
|
| 77 |
+
)
|
| 78 |
+
return instance
|
| 79 |
+
|
| 80 |
+
def to_dict(self):
|
| 81 |
+
return asdict(self)
|
| 82 |
+
|
| 83 |
+
def get(self, key: str, default: Any = None):
|
| 84 |
+
return getattr(self, key) if hasattr(self, key) else default
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
# ---------------------------------------------------------------------------
|
| 88 |
+
# Gr00tDatasetMetadata
|
| 89 |
+
# ---------------------------------------------------------------------------
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
class Gr00tDatasetMetadata(LeRobotDatasetMetadata):
|
| 93 |
+
"""Additional metadata on top of LeRobotDatasetMetadata:
|
| 94 |
+
- modality_config: Written to ``meta/modality.json``
|
| 95 |
+
- discarded_episode_indices: Written to ``meta/info.json``
|
| 96 |
+
"""
|
| 97 |
+
|
| 98 |
+
MODALITY_CONFIG_REL_PATH = Path("meta/modality.json")
|
| 99 |
+
|
| 100 |
+
def __init__(self, *args, **kwargs):
|
| 101 |
+
super().__init__(*args, **kwargs)
|
| 102 |
+
with open(self.root / self.MODALITY_CONFIG_REL_PATH, "rb") as f:
|
| 103 |
+
self.modality_config = json.load(f)
|
| 104 |
+
|
| 105 |
+
@classmethod
|
| 106 |
+
def create(
|
| 107 |
+
cls,
|
| 108 |
+
modality_config: dict,
|
| 109 |
+
script_config: dict,
|
| 110 |
+
*args,
|
| 111 |
+
**kwargs,
|
| 112 |
+
):
|
| 113 |
+
cls.validate_modality_config(modality_config)
|
| 114 |
+
|
| 115 |
+
obj = super().create(*args, **kwargs)
|
| 116 |
+
|
| 117 |
+
obj.info["script_config"] = script_config
|
| 118 |
+
obj.info["discarded_episode_indices"] = []
|
| 119 |
+
with open(obj.root / "meta" / "info.json", "w") as f:
|
| 120 |
+
json.dump(obj.info, f, indent=4)
|
| 121 |
+
|
| 122 |
+
obj.__class__ = cls
|
| 123 |
+
with open(obj.root / cls.MODALITY_CONFIG_REL_PATH, "w") as f:
|
| 124 |
+
json.dump(modality_config, f, indent=4)
|
| 125 |
+
obj.modality_config = modality_config
|
| 126 |
+
return obj
|
| 127 |
+
|
| 128 |
+
@staticmethod
|
| 129 |
+
def validate_modality_config(modality_config: dict) -> None:
|
| 130 |
+
valid_keys = ["state", "action", "video", "annotation"]
|
| 131 |
+
if not all(key in modality_config for key in valid_keys):
|
| 132 |
+
raise ValueError(
|
| 133 |
+
f"Modality config must contain all of the following keys: {valid_keys}"
|
| 134 |
+
)
|
| 135 |
+
for key in valid_keys:
|
| 136 |
+
if key not in modality_config:
|
| 137 |
+
raise ValueError(f"Modality config must contain a '{key}' key")
|
| 138 |
+
|
| 139 |
+
|
| 140 |
+
# ---------------------------------------------------------------------------
|
| 141 |
+
# Gr00tDataExporter
|
| 142 |
+
# ---------------------------------------------------------------------------
|
| 143 |
+
|
| 144 |
+
|
| 145 |
+
class Gr00tDataExporter(LeRobotDataset):
|
| 146 |
+
"""Exports data collected for a single session to LeRobot Dataset.
|
| 147 |
+
|
| 148 |
+
Lifecycle:
|
| 149 |
+
1. Create a Gr00tDataExporter object
|
| 150 |
+
2. Add frames using add_frame()
|
| 151 |
+
3. Save the episode using save_episode()
|
| 152 |
+
- Flushes the episode buffer to disk
|
| 153 |
+
- Closes the video writers
|
| 154 |
+
- Creates new video writer and ep buffer for the next episode
|
| 155 |
+
"""
|
| 156 |
+
|
| 157 |
+
def __init__(self, *args, **kwargs):
|
| 158 |
+
super().__init__(*args, **kwargs)
|
| 159 |
+
self.video_writers = self.create_video_writer()
|
| 160 |
+
|
| 161 |
+
@property
|
| 162 |
+
def repo_id(self):
|
| 163 |
+
return self.meta.repo_id
|
| 164 |
+
|
| 165 |
+
@property
|
| 166 |
+
def root(self):
|
| 167 |
+
return self.meta.root
|
| 168 |
+
|
| 169 |
+
@property
|
| 170 |
+
def local_files_only(self):
|
| 171 |
+
return self.meta.local_files_only
|
| 172 |
+
|
| 173 |
+
@property
|
| 174 |
+
def video_keys(self):
|
| 175 |
+
return self.meta.video_keys
|
| 176 |
+
|
| 177 |
+
@classmethod
|
| 178 |
+
def create(
|
| 179 |
+
cls,
|
| 180 |
+
save_root: str | Path,
|
| 181 |
+
fps: int,
|
| 182 |
+
features: dict,
|
| 183 |
+
modality_config: dict,
|
| 184 |
+
task: str,
|
| 185 |
+
script_config: ArgsConfig | dict = None,
|
| 186 |
+
robot_type: str | None = None,
|
| 187 |
+
tolerance_s: float = 1e-4,
|
| 188 |
+
vcodec: str = "h264",
|
| 189 |
+
overwrite_existing: bool = False,
|
| 190 |
+
) -> "Gr00tDataExporter":
|
| 191 |
+
if script_config is None:
|
| 192 |
+
script_config = {}
|
| 193 |
+
|
| 194 |
+
obj = cls.__new__(cls)
|
| 195 |
+
repo_id = "tmp/tmp_dataset"
|
| 196 |
+
|
| 197 |
+
if overwrite_existing and (Path(save_root)).exists():
|
| 198 |
+
print(
|
| 199 |
+
f"Found existing dataset at {save_root}",
|
| 200 |
+
"Cleaning up this directory since overwrite_existing is True.",
|
| 201 |
+
)
|
| 202 |
+
shutil.rmtree(save_root)
|
| 203 |
+
|
| 204 |
+
if (Path(save_root)).exists():
|
| 205 |
+
try:
|
| 206 |
+
obj.meta = Gr00tDatasetMetadata(
|
| 207 |
+
repo_id=repo_id,
|
| 208 |
+
root=save_root,
|
| 209 |
+
)
|
| 210 |
+
except RepositoryNotFoundError as e:
|
| 211 |
+
raise ValueError(
|
| 212 |
+
f"Failed to resume from corrupted dataset. "
|
| 213 |
+
f"Please manually check the dataset at {save_root}"
|
| 214 |
+
) from e
|
| 215 |
+
else:
|
| 216 |
+
if not isinstance(script_config, dict):
|
| 217 |
+
script_config = script_config.to_dict()
|
| 218 |
+
obj.meta = Gr00tDatasetMetadata.create(
|
| 219 |
+
repo_id=repo_id,
|
| 220 |
+
fps=fps,
|
| 221 |
+
root=save_root,
|
| 222 |
+
robot=None,
|
| 223 |
+
robot_type=robot_type,
|
| 224 |
+
features=features,
|
| 225 |
+
modality_config=modality_config,
|
| 226 |
+
script_config=script_config,
|
| 227 |
+
use_videos=True,
|
| 228 |
+
)
|
| 229 |
+
|
| 230 |
+
obj.tolerance_s = tolerance_s
|
| 231 |
+
obj.video_backend = "pyav"
|
| 232 |
+
obj.vcodec = vcodec
|
| 233 |
+
obj.task = task
|
| 234 |
+
obj.image_writer = None
|
| 235 |
+
|
| 236 |
+
obj.episode_buffer = obj.create_episode_buffer()
|
| 237 |
+
|
| 238 |
+
obj.episodes = None
|
| 239 |
+
obj.hf_dataset = obj.create_hf_dataset()
|
| 240 |
+
obj.image_transforms = None
|
| 241 |
+
obj.delta_timestamps = None
|
| 242 |
+
obj.delta_indices = None
|
| 243 |
+
obj.episode_data_index = None
|
| 244 |
+
obj.video_writers = obj.create_video_writer()
|
| 245 |
+
return obj
|
| 246 |
+
|
| 247 |
+
def create_video_writer(self) -> dict[str, VideoWriter]:
|
| 248 |
+
video_writers = {}
|
| 249 |
+
for key in self.meta.video_keys:
|
| 250 |
+
video_writers[key] = VideoWriter(
|
| 251 |
+
self.root
|
| 252 |
+
/ self.meta.get_video_file_path(self.episode_buffer["episode_index"], key),
|
| 253 |
+
self.meta.shapes[key][1],
|
| 254 |
+
self.meta.shapes[key][0],
|
| 255 |
+
self.fps,
|
| 256 |
+
self.vcodec,
|
| 257 |
+
)
|
| 258 |
+
return video_writers
|
| 259 |
+
|
| 260 |
+
def add_frame(self, frame: dict) -> None:
|
| 261 |
+
"""Add a frame to the episode buffer. Videos are handled by the video_writer."""
|
| 262 |
+
frame = copy.deepcopy(frame)
|
| 263 |
+
frame["task"] = frame.get("task", self.task)
|
| 264 |
+
|
| 265 |
+
for name in frame:
|
| 266 |
+
if isinstance(frame[name], torch.Tensor):
|
| 267 |
+
frame[name] = frame[name].numpy()
|
| 268 |
+
|
| 269 |
+
validate_frame(frame, self.features)
|
| 270 |
+
|
| 271 |
+
if self.episode_buffer is None:
|
| 272 |
+
self.episode_buffer = self.create_episode_buffer()
|
| 273 |
+
|
| 274 |
+
frame_index = self.episode_buffer["size"]
|
| 275 |
+
timestamp = frame.pop("timestamp") if "timestamp" in frame else frame_index / self.fps
|
| 276 |
+
self.episode_buffer["frame_index"].append(frame_index)
|
| 277 |
+
self.episode_buffer["timestamp"].append(timestamp)
|
| 278 |
+
|
| 279 |
+
for key in frame:
|
| 280 |
+
if key == "task":
|
| 281 |
+
self.episode_buffer["task"].append(frame["task"])
|
| 282 |
+
continue
|
| 283 |
+
|
| 284 |
+
if key not in self.features:
|
| 285 |
+
raise ValueError(
|
| 286 |
+
f"An element of the frame is not in the features. "
|
| 287 |
+
f"'{key}' not in '{self.features.keys()}'."
|
| 288 |
+
)
|
| 289 |
+
|
| 290 |
+
if self.features[key]["dtype"] in ["image", "video"]:
|
| 291 |
+
img_path = self._get_image_file_path(
|
| 292 |
+
episode_index=self.episode_buffer["episode_index"],
|
| 293 |
+
image_key=key,
|
| 294 |
+
frame_index=frame_index,
|
| 295 |
+
)
|
| 296 |
+
if frame_index == 0:
|
| 297 |
+
img_path.parent.mkdir(parents=True, exist_ok=True)
|
| 298 |
+
|
| 299 |
+
self.video_writers[key].add_frame(frame[key])
|
| 300 |
+
self.episode_buffer[key].append(str(img_path))
|
| 301 |
+
else:
|
| 302 |
+
self.episode_buffer[key].append(frame[key])
|
| 303 |
+
|
| 304 |
+
self.episode_buffer["size"] += 1
|
| 305 |
+
|
| 306 |
+
def stop_video_writers(self):
|
| 307 |
+
if not hasattr(self, "video_writers"):
|
| 308 |
+
raise RuntimeError(
|
| 309 |
+
"Can't stop video writers because they haven't been initialized. Call create() first."
|
| 310 |
+
)
|
| 311 |
+
for key in self.video_writers:
|
| 312 |
+
self.video_writers[key].stop()
|
| 313 |
+
|
| 314 |
+
def skip_and_start_new_episode(self) -> None:
|
| 315 |
+
"""Skip the current episode and start a new one."""
|
| 316 |
+
self.stop_video_writers()
|
| 317 |
+
self.episode_buffer = self.create_episode_buffer()
|
| 318 |
+
self.video_writers = self.create_video_writer()
|
| 319 |
+
|
| 320 |
+
def save_episode(self, episode_data: dict | None = None) -> None:
|
| 321 |
+
if not episode_data:
|
| 322 |
+
episode_buffer = self.episode_buffer
|
| 323 |
+
|
| 324 |
+
validate_episode_buffer(episode_buffer, self.meta.total_episodes, self.features)
|
| 325 |
+
|
| 326 |
+
episode_length = episode_buffer.pop("size")
|
| 327 |
+
tasks = episode_buffer.pop("task")
|
| 328 |
+
episode_tasks = list(set(tasks))
|
| 329 |
+
episode_index = episode_buffer["episode_index"]
|
| 330 |
+
|
| 331 |
+
episode_buffer["index"] = np.arange(
|
| 332 |
+
self.meta.total_frames, self.meta.total_frames + episode_length
|
| 333 |
+
)
|
| 334 |
+
episode_buffer["episode_index"] = np.full((episode_length,), episode_index)
|
| 335 |
+
|
| 336 |
+
for task in episode_tasks:
|
| 337 |
+
task_index = self.meta.get_task_index(task)
|
| 338 |
+
if task_index is None:
|
| 339 |
+
self.meta.add_task(task)
|
| 340 |
+
|
| 341 |
+
episode_buffer["task_index"] = np.array([self.meta.get_task_index(task) for task in tasks])
|
| 342 |
+
|
| 343 |
+
for key, ft in self.features.items():
|
| 344 |
+
if key in ["index", "episode_index", "task_index"] or ft["dtype"] in ["image", "video"]:
|
| 345 |
+
continue
|
| 346 |
+
episode_buffer[key] = np.stack(episode_buffer[key])
|
| 347 |
+
|
| 348 |
+
self._wait_image_writer()
|
| 349 |
+
self._save_episode_table(episode_buffer, episode_index)
|
| 350 |
+
|
| 351 |
+
non_video_features = {k: v for k, v in self.features.items() if v["dtype"] not in ["video"]}
|
| 352 |
+
non_vid_ep_buffer = {
|
| 353 |
+
k: v for k, v in episode_buffer.items() if k in non_video_features.keys()
|
| 354 |
+
}
|
| 355 |
+
ep_stats = compute_episode_stats(non_vid_ep_buffer, non_video_features)
|
| 356 |
+
|
| 357 |
+
if len(self.meta.video_keys) > 0:
|
| 358 |
+
video_paths = self.encode_episode_videos(episode_index)
|
| 359 |
+
for key in self.meta.video_keys:
|
| 360 |
+
episode_buffer[key] = video_paths[key]
|
| 361 |
+
|
| 362 |
+
self.meta.save_episode(episode_index, episode_length, episode_tasks, ep_stats)
|
| 363 |
+
|
| 364 |
+
ep_data_index = get_episode_data_index(self.meta.episodes, [episode_index])
|
| 365 |
+
ep_data_index_np = {k: t.numpy() for k, t in ep_data_index.items()}
|
| 366 |
+
check_timestamps_sync(
|
| 367 |
+
episode_buffer["timestamp"],
|
| 368 |
+
episode_buffer["episode_index"],
|
| 369 |
+
ep_data_index_np,
|
| 370 |
+
self.fps,
|
| 371 |
+
self.tolerance_s,
|
| 372 |
+
)
|
| 373 |
+
|
| 374 |
+
video_files = list(self.root.rglob("*.mp4"))
|
| 375 |
+
assert len(video_files) == self.num_episodes * len(self.meta.video_keys)
|
| 376 |
+
|
| 377 |
+
parquet_files = list(self.root.rglob("*.parquet"))
|
| 378 |
+
assert len(parquet_files) == self.num_episodes
|
| 379 |
+
|
| 380 |
+
img_dir = self.root / "images"
|
| 381 |
+
if img_dir.is_dir():
|
| 382 |
+
shutil.rmtree(self.root / "images")
|
| 383 |
+
|
| 384 |
+
if not episode_data:
|
| 385 |
+
self.episode_buffer = self.create_episode_buffer()
|
| 386 |
+
self.video_writers = self.create_video_writer()
|
| 387 |
+
|
| 388 |
+
for key in self.meta.video_keys:
|
| 389 |
+
video_path = os.path.join(self.root, self.meta.get_video_file_path(episode_index, key))
|
| 390 |
+
if not os.path.exists(video_path):
|
| 391 |
+
raise FileNotFoundError(
|
| 392 |
+
f"Video path: {video_path} does not exist for episode {episode_index}"
|
| 393 |
+
)
|
| 394 |
+
|
| 395 |
+
parquet_path = os.path.join(self.root, self.meta.get_data_file_path(episode_index))
|
| 396 |
+
if not os.path.exists(parquet_path):
|
| 397 |
+
raise FileNotFoundError(
|
| 398 |
+
f"Parquet path: {parquet_path} does not exist for episode {episode_index}"
|
| 399 |
+
)
|
| 400 |
+
|
| 401 |
+
def encode_episode_videos(self, episode_index: int) -> dict:
|
| 402 |
+
video_paths = {}
|
| 403 |
+
for key in self.meta.video_keys:
|
| 404 |
+
video_paths[key] = self.video_writers[key].stop()
|
| 405 |
+
return video_paths
|
| 406 |
+
|
| 407 |
+
def save_episode_as_discarded(self) -> None:
|
| 408 |
+
"""Flag ongoing episode as discarded and save it to disk."""
|
| 409 |
+
self.meta.info["discarded_episode_indices"] = self.meta.info.get(
|
| 410 |
+
"discarded_episode_indices", []
|
| 411 |
+
) + [self.episode_buffer["episode_index"]]
|
| 412 |
+
self.save_episode()
|
| 413 |
+
|
| 414 |
+
|
| 415 |
+
# ---------------------------------------------------------------------------
|
| 416 |
+
# HF dataset helpers (for loading saved datasets)
|
| 417 |
+
# ---------------------------------------------------------------------------
|
| 418 |
+
|
| 419 |
+
|
| 420 |
+
def hf_transform_to_torch_by_features(
|
| 421 |
+
features: datasets.Sequence, items_dict: dict[torch.Tensor | None]
|
| 422 |
+
):
|
| 423 |
+
for key in items_dict:
|
| 424 |
+
first_item = items_dict[key][0]
|
| 425 |
+
if isinstance(first_item, PILImage.Image):
|
| 426 |
+
to_tensor = transforms.ToTensor()
|
| 427 |
+
items_dict[key] = [to_tensor(img) for img in items_dict[key]]
|
| 428 |
+
elif first_item is None:
|
| 429 |
+
pass
|
| 430 |
+
else:
|
| 431 |
+
if isinstance(features[key], datasets.Value):
|
| 432 |
+
dtype_str = features[key].dtype
|
| 433 |
+
elif isinstance(features[key], datasets.Sequence):
|
| 434 |
+
assert isinstance(features[key].feature, datasets.Value)
|
| 435 |
+
dtype_str = features[key].feature.dtype
|
| 436 |
+
else:
|
| 437 |
+
raise ValueError(f"Unsupported feature type for key '{key}': {features[key]}")
|
| 438 |
+
dtype_mapping = {
|
| 439 |
+
"float32": torch.float32,
|
| 440 |
+
"float64": torch.float64,
|
| 441 |
+
"int32": torch.int32,
|
| 442 |
+
"int64": torch.int64,
|
| 443 |
+
}
|
| 444 |
+
items_dict[key] = [
|
| 445 |
+
torch.tensor(x, dtype=dtype_mapping[dtype_str]) for x in items_dict[key]
|
| 446 |
+
]
|
| 447 |
+
return items_dict
|
| 448 |
+
|
| 449 |
+
|
| 450 |
+
class TypedLeRobotDataset(LeRobotDataset):
|
| 451 |
+
def __init__(self, load_video=True, *args, **kwargs):
|
| 452 |
+
super().__init__(*args, **kwargs)
|
| 453 |
+
if not load_video:
|
| 454 |
+
video_keys = []
|
| 455 |
+
for key in self.meta.features.keys():
|
| 456 |
+
if self.meta.features[key]["dtype"] == "video":
|
| 457 |
+
video_keys.append(key)
|
| 458 |
+
for key in video_keys:
|
| 459 |
+
self.meta.features.pop(key)
|
| 460 |
+
|
| 461 |
+
def load_hf_dataset(self) -> datasets.Dataset:
|
| 462 |
+
if self.episodes is None:
|
| 463 |
+
path = str(self.root / "data")
|
| 464 |
+
hf_dataset = load_dataset("parquet", data_dir=path, split="train")
|
| 465 |
+
else:
|
| 466 |
+
files = [
|
| 467 |
+
str(self.root / self.meta.get_data_file_path(ep_idx)) for ep_idx in self.episodes
|
| 468 |
+
]
|
| 469 |
+
hf_dataset = load_dataset("parquet", data_files=files, split="train")
|
| 470 |
+
|
| 471 |
+
hf_dataset.set_transform(partial(hf_transform_to_torch_by_features, hf_dataset.features))
|
| 472 |
+
return hf_dataset
|
GR00T-WholeBodyControl/gear_sonic/data/features_sonic_vla.py
ADDED
|
@@ -0,0 +1,410 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""
|
| 2 |
+
Dataset configuration for the Sonic VLA pipeline.
|
| 3 |
+
|
| 4 |
+
Provides feature/modality config dicts and a convenience function to
|
| 5 |
+
instantiate the G1 RobotModel needed for FK and joint configuration
|
| 6 |
+
assembly during data collection.
|
| 7 |
+
|
| 8 |
+
Joint names, counts, and group indices are derived at runtime from the
|
| 9 |
+
``RobotModel`` (via its ``supplemental_info``).
|
| 10 |
+
"""
|
| 11 |
+
|
| 12 |
+
from __future__ import annotations
|
| 13 |
+
|
| 14 |
+
from typing import Literal
|
| 15 |
+
|
| 16 |
+
from gear_sonic.data.robot_model import RobotModel
|
| 17 |
+
|
| 18 |
+
EGO_VIEW_HEIGHT: int = 480
|
| 19 |
+
EGO_VIEW_WIDTH: int = 640
|
| 20 |
+
WRIST_VIEW_HEIGHT: int = 480
|
| 21 |
+
WRIST_VIEW_WIDTH: int = 640
|
| 22 |
+
FPS: int = 50
|
| 23 |
+
|
| 24 |
+
|
| 25 |
+
_JOINT_GROUPS_FOR_STATE: list[str] = [
|
| 26 |
+
"left_leg",
|
| 27 |
+
"right_leg",
|
| 28 |
+
"waist",
|
| 29 |
+
"left_arm",
|
| 30 |
+
"left_hand",
|
| 31 |
+
"right_arm",
|
| 32 |
+
"right_hand",
|
| 33 |
+
]
|
| 34 |
+
|
| 35 |
+
|
| 36 |
+
def _get_joint_group_slices(robot_model: RobotModel) -> dict[str, dict[str, int]]:
|
| 37 |
+
"""Derive ``{group_name: {"start": ..., "end": ...}}`` from the robot model."""
|
| 38 |
+
slices: dict[str, dict[str, int]] = {}
|
| 39 |
+
for group in _JOINT_GROUPS_FOR_STATE:
|
| 40 |
+
indices = sorted(robot_model.get_joint_group_indices(group))
|
| 41 |
+
slices[group] = {"start": indices[0], "end": indices[-1] + 1}
|
| 42 |
+
return slices
|
| 43 |
+
|
| 44 |
+
|
| 45 |
+
def get_modality_config_sonic_vla(robot_model: RobotModel) -> dict:
|
| 46 |
+
"""Return the modality config for the Sonic VLA dataset.
|
| 47 |
+
|
| 48 |
+
Produces the exact content of meta/modality.json.
|
| 49 |
+
"""
|
| 50 |
+
group_slices = _get_joint_group_slices(robot_model)
|
| 51 |
+
|
| 52 |
+
return {
|
| 53 |
+
"state": {
|
| 54 |
+
**group_slices,
|
| 55 |
+
"left_wrist_pos": {
|
| 56 |
+
"start": 0,
|
| 57 |
+
"end": 3,
|
| 58 |
+
"original_key": "observation.eef_state",
|
| 59 |
+
},
|
| 60 |
+
"left_wrist_abs_quat": {
|
| 61 |
+
"start": 3,
|
| 62 |
+
"end": 7,
|
| 63 |
+
"original_key": "observation.eef_state",
|
| 64 |
+
"rotation_type": "quaternion",
|
| 65 |
+
},
|
| 66 |
+
"right_wrist_pos": {
|
| 67 |
+
"start": 7,
|
| 68 |
+
"end": 10,
|
| 69 |
+
"original_key": "observation.eef_state",
|
| 70 |
+
},
|
| 71 |
+
"right_wrist_abs_quat": {
|
| 72 |
+
"start": 10,
|
| 73 |
+
"end": 14,
|
| 74 |
+
"original_key": "observation.eef_state",
|
| 75 |
+
"rotation_type": "quaternion",
|
| 76 |
+
},
|
| 77 |
+
"root_orientation": {
|
| 78 |
+
"start": 0,
|
| 79 |
+
"end": 4,
|
| 80 |
+
"original_key": "observation.root_orientation",
|
| 81 |
+
"rotation_type": "quaternion",
|
| 82 |
+
},
|
| 83 |
+
"projected_gravity": {
|
| 84 |
+
"start": 0,
|
| 85 |
+
"end": 3,
|
| 86 |
+
"original_key": "observation.projected_gravity",
|
| 87 |
+
},
|
| 88 |
+
"cpp_rotation_offset": {
|
| 89 |
+
"start": 0,
|
| 90 |
+
"end": 4,
|
| 91 |
+
"original_key": "observation.cpp_rotation_offset",
|
| 92 |
+
"rotation_type": "quaternion",
|
| 93 |
+
},
|
| 94 |
+
"init_base_quat": {
|
| 95 |
+
"start": 0,
|
| 96 |
+
"end": 4,
|
| 97 |
+
"original_key": "observation.init_base_quat",
|
| 98 |
+
"rotation_type": "quaternion",
|
| 99 |
+
},
|
| 100 |
+
},
|
| 101 |
+
"action": {
|
| 102 |
+
"delta_heading": {
|
| 103 |
+
"start": 0,
|
| 104 |
+
"end": 1,
|
| 105 |
+
"original_key": "teleop.delta_heading",
|
| 106 |
+
},
|
| 107 |
+
"motion_token": {
|
| 108 |
+
"start": 0,
|
| 109 |
+
"end": 64,
|
| 110 |
+
"original_key": "action.motion_token",
|
| 111 |
+
},
|
| 112 |
+
"smpl_joints": {
|
| 113 |
+
"start": 0,
|
| 114 |
+
"end": 72,
|
| 115 |
+
"original_key": "teleop.smpl_joints",
|
| 116 |
+
},
|
| 117 |
+
"smpl_pose": {
|
| 118 |
+
"start": 0,
|
| 119 |
+
"end": 63,
|
| 120 |
+
"original_key": "teleop.smpl_pose",
|
| 121 |
+
},
|
| 122 |
+
"body_quat_w": {
|
| 123 |
+
"start": 0,
|
| 124 |
+
"end": 4,
|
| 125 |
+
"original_key": "teleop.body_quat_w",
|
| 126 |
+
"rotation_type": "quaternion",
|
| 127 |
+
},
|
| 128 |
+
"target_body_orientation": {
|
| 129 |
+
"start": 0,
|
| 130 |
+
"end": 6,
|
| 131 |
+
"original_key": "teleop.target_body_orientation",
|
| 132 |
+
"rotation_type": "rotation_6d",
|
| 133 |
+
},
|
| 134 |
+
"left_hand_joints": {
|
| 135 |
+
"start": 0,
|
| 136 |
+
"end": 7,
|
| 137 |
+
"original_key": "teleop.left_hand_joints",
|
| 138 |
+
},
|
| 139 |
+
"right_hand_joints": {
|
| 140 |
+
"start": 0,
|
| 141 |
+
"end": 7,
|
| 142 |
+
"original_key": "teleop.right_hand_joints",
|
| 143 |
+
},
|
| 144 |
+
"left_wrist_joints": {
|
| 145 |
+
"start": 0,
|
| 146 |
+
"end": 3,
|
| 147 |
+
"original_key": "teleop.left_wrist_joints",
|
| 148 |
+
},
|
| 149 |
+
"right_wrist_joints": {
|
| 150 |
+
"start": 0,
|
| 151 |
+
"end": 3,
|
| 152 |
+
"original_key": "teleop.right_wrist_joints",
|
| 153 |
+
},
|
| 154 |
+
"stream_mode": {
|
| 155 |
+
"start": 0,
|
| 156 |
+
"end": 1,
|
| 157 |
+
"original_key": "teleop.stream_mode",
|
| 158 |
+
},
|
| 159 |
+
"planner_mode": {
|
| 160 |
+
"start": 0,
|
| 161 |
+
"end": 1,
|
| 162 |
+
"original_key": "teleop.planner_mode",
|
| 163 |
+
},
|
| 164 |
+
"planner_movement": {
|
| 165 |
+
"start": 0,
|
| 166 |
+
"end": 3,
|
| 167 |
+
"original_key": "teleop.planner_movement",
|
| 168 |
+
},
|
| 169 |
+
"planner_facing": {
|
| 170 |
+
"start": 0,
|
| 171 |
+
"end": 3,
|
| 172 |
+
"original_key": "teleop.planner_facing",
|
| 173 |
+
},
|
| 174 |
+
"planner_speed": {
|
| 175 |
+
"start": 0,
|
| 176 |
+
"end": 1,
|
| 177 |
+
"original_key": "teleop.planner_speed",
|
| 178 |
+
},
|
| 179 |
+
"planner_height": {
|
| 180 |
+
"start": 0,
|
| 181 |
+
"end": 1,
|
| 182 |
+
"original_key": "teleop.planner_height",
|
| 183 |
+
},
|
| 184 |
+
"vr_3pt_position": {
|
| 185 |
+
"start": 0,
|
| 186 |
+
"end": 9,
|
| 187 |
+
"original_key": "teleop.vr_3pt_position",
|
| 188 |
+
},
|
| 189 |
+
"vr_3pt_orientation": {
|
| 190 |
+
"start": 0,
|
| 191 |
+
"end": 18,
|
| 192 |
+
"original_key": "teleop.vr_3pt_orientation",
|
| 193 |
+
"rotation_type": "rotation_6d",
|
| 194 |
+
},
|
| 195 |
+
},
|
| 196 |
+
"video": {
|
| 197 |
+
"ego_view": {"original_key": "observation.images.ego_view"},
|
| 198 |
+
},
|
| 199 |
+
"annotation": {
|
| 200 |
+
"human.task_description": {"original_key": "task_index"},
|
| 201 |
+
},
|
| 202 |
+
}
|
| 203 |
+
|
| 204 |
+
|
| 205 |
+
def get_features_sonic_vla(robot_model: RobotModel) -> dict:
|
| 206 |
+
"""Return the dataset features for the Sonic VLA dataset.
|
| 207 |
+
|
| 208 |
+
The returned dict populates the "features" key of meta/info.json.
|
| 209 |
+
"""
|
| 210 |
+
joint_names = robot_model.joint_names
|
| 211 |
+
num_joints = robot_model.num_joints
|
| 212 |
+
|
| 213 |
+
return {
|
| 214 |
+
"observation.images.ego_view": {
|
| 215 |
+
"dtype": "video",
|
| 216 |
+
"shape": [EGO_VIEW_HEIGHT, EGO_VIEW_WIDTH, 3],
|
| 217 |
+
"names": ["height", "width", "channel"],
|
| 218 |
+
},
|
| 219 |
+
"observation.state": {
|
| 220 |
+
"dtype": "float64",
|
| 221 |
+
"shape": (num_joints,),
|
| 222 |
+
"names": joint_names,
|
| 223 |
+
},
|
| 224 |
+
"observation.eef_state": {
|
| 225 |
+
"dtype": "float64",
|
| 226 |
+
"shape": (14,),
|
| 227 |
+
"names": [
|
| 228 |
+
"left_wrist_pos",
|
| 229 |
+
"left_wrist_abs_quat",
|
| 230 |
+
"right_wrist_pos",
|
| 231 |
+
"right_wrist_abs_quat",
|
| 232 |
+
],
|
| 233 |
+
},
|
| 234 |
+
"action.wbc": {
|
| 235 |
+
"dtype": "float64",
|
| 236 |
+
"shape": (num_joints,),
|
| 237 |
+
"names": joint_names,
|
| 238 |
+
},
|
| 239 |
+
"observation.root_orientation": {
|
| 240 |
+
"dtype": "float64",
|
| 241 |
+
"shape": (4,),
|
| 242 |
+
"names": ["base_qw", "base_qx", "base_qy", "base_qz"],
|
| 243 |
+
},
|
| 244 |
+
"observation.projected_gravity": {
|
| 245 |
+
"dtype": "float64",
|
| 246 |
+
"shape": (3,),
|
| 247 |
+
"names": ["gravity_x", "gravity_y", "gravity_z"],
|
| 248 |
+
},
|
| 249 |
+
"observation.cpp_rotation_offset": {
|
| 250 |
+
"dtype": "float64",
|
| 251 |
+
"shape": (4,),
|
| 252 |
+
"names": ["rot_offset_qw", "rot_offset_qx", "rot_offset_qy", "rot_offset_qz"],
|
| 253 |
+
},
|
| 254 |
+
"observation.init_base_quat": {
|
| 255 |
+
"dtype": "float64",
|
| 256 |
+
"shape": (4,),
|
| 257 |
+
"names": ["init_base_qw", "init_base_qx", "init_base_qy", "init_base_qz"],
|
| 258 |
+
},
|
| 259 |
+
"teleop.delta_heading": {
|
| 260 |
+
"dtype": "float64",
|
| 261 |
+
"shape": (1,),
|
| 262 |
+
"names": ["delta_heading"],
|
| 263 |
+
},
|
| 264 |
+
"action.motion_token": {
|
| 265 |
+
"dtype": "float64",
|
| 266 |
+
"shape": (64,),
|
| 267 |
+
"names": "motion_token",
|
| 268 |
+
},
|
| 269 |
+
"teleop.smpl_joints": {
|
| 270 |
+
"dtype": "float32",
|
| 271 |
+
"shape": (72,),
|
| 272 |
+
"names": "smpl_joints",
|
| 273 |
+
},
|
| 274 |
+
"teleop.smpl_pose": {
|
| 275 |
+
"dtype": "float32",
|
| 276 |
+
"shape": (63,),
|
| 277 |
+
"names": "smpl_pose",
|
| 278 |
+
},
|
| 279 |
+
"teleop.body_quat_w": {
|
| 280 |
+
"dtype": "float32",
|
| 281 |
+
"shape": (4,),
|
| 282 |
+
"names": "body_quat_w",
|
| 283 |
+
},
|
| 284 |
+
"teleop.target_body_orientation": {
|
| 285 |
+
"dtype": "float32",
|
| 286 |
+
"shape": (6,),
|
| 287 |
+
"names": [
|
| 288 |
+
"target_body_r00",
|
| 289 |
+
"target_body_r10",
|
| 290 |
+
"target_body_r01",
|
| 291 |
+
"target_body_r11",
|
| 292 |
+
"target_body_r02",
|
| 293 |
+
"target_body_r12",
|
| 294 |
+
],
|
| 295 |
+
},
|
| 296 |
+
"teleop.left_hand_joints": {
|
| 297 |
+
"dtype": "float32",
|
| 298 |
+
"shape": (7,),
|
| 299 |
+
"names": "left_hand_joints",
|
| 300 |
+
},
|
| 301 |
+
"teleop.right_hand_joints": {
|
| 302 |
+
"dtype": "float32",
|
| 303 |
+
"shape": (7,),
|
| 304 |
+
"names": "right_hand_joints",
|
| 305 |
+
},
|
| 306 |
+
"teleop.smpl_frame_index": {
|
| 307 |
+
"dtype": "int64",
|
| 308 |
+
"shape": (1,),
|
| 309 |
+
"names": ["smpl_frame_index"],
|
| 310 |
+
},
|
| 311 |
+
"teleop.left_wrist_joints": {
|
| 312 |
+
"dtype": "float32",
|
| 313 |
+
"shape": (3,),
|
| 314 |
+
"names": ["left_wrist_roll", "left_wrist_pitch", "left_wrist_yaw"],
|
| 315 |
+
},
|
| 316 |
+
"teleop.right_wrist_joints": {
|
| 317 |
+
"dtype": "float32",
|
| 318 |
+
"shape": (3,),
|
| 319 |
+
"names": ["right_wrist_roll", "right_wrist_pitch", "right_wrist_yaw"],
|
| 320 |
+
},
|
| 321 |
+
"teleop.stream_mode": {
|
| 322 |
+
"dtype": "int32",
|
| 323 |
+
"shape": (1,),
|
| 324 |
+
"names": ["stream_mode"],
|
| 325 |
+
},
|
| 326 |
+
"teleop.planner_mode": {
|
| 327 |
+
"dtype": "int32",
|
| 328 |
+
"shape": (1,),
|
| 329 |
+
"names": ["locomotion_mode"],
|
| 330 |
+
},
|
| 331 |
+
"teleop.planner_movement": {
|
| 332 |
+
"dtype": "float32",
|
| 333 |
+
"shape": (3,),
|
| 334 |
+
"names": ["movement_x", "movement_y", "movement_z"],
|
| 335 |
+
},
|
| 336 |
+
"teleop.planner_facing": {
|
| 337 |
+
"dtype": "float32",
|
| 338 |
+
"shape": (3,),
|
| 339 |
+
"names": ["facing_x", "facing_y", "facing_z"],
|
| 340 |
+
},
|
| 341 |
+
"teleop.planner_speed": {
|
| 342 |
+
"dtype": "float32",
|
| 343 |
+
"shape": (1,),
|
| 344 |
+
"names": ["speed"],
|
| 345 |
+
},
|
| 346 |
+
"teleop.planner_height": {
|
| 347 |
+
"dtype": "float32",
|
| 348 |
+
"shape": (1,),
|
| 349 |
+
"names": ["height"],
|
| 350 |
+
},
|
| 351 |
+
"teleop.vr_3pt_position": {
|
| 352 |
+
"dtype": "float32",
|
| 353 |
+
"shape": (9,),
|
| 354 |
+
"names": [
|
| 355 |
+
"lwrist_x", "lwrist_y", "lwrist_z",
|
| 356 |
+
"rwrist_x", "rwrist_y", "rwrist_z",
|
| 357 |
+
"neck_x", "neck_y", "neck_z",
|
| 358 |
+
],
|
| 359 |
+
},
|
| 360 |
+
"teleop.vr_3pt_orientation": {
|
| 361 |
+
"dtype": "float32",
|
| 362 |
+
"shape": (18,),
|
| 363 |
+
"names": [
|
| 364 |
+
"lwrist_r00", "lwrist_r10", "lwrist_r01", "lwrist_r11", "lwrist_r02", "lwrist_r12",
|
| 365 |
+
"rwrist_r00", "rwrist_r10", "rwrist_r01", "rwrist_r11", "rwrist_r02", "rwrist_r12",
|
| 366 |
+
"neck_r00", "neck_r10", "neck_r01", "neck_r11", "neck_r02", "neck_r12",
|
| 367 |
+
],
|
| 368 |
+
},
|
| 369 |
+
}
|
| 370 |
+
|
| 371 |
+
|
| 372 |
+
def get_wrist_camera_features() -> dict:
|
| 373 |
+
"""Features for optional wrist cameras (added when ``record_wrist_cameras`` is enabled)."""
|
| 374 |
+
return {
|
| 375 |
+
"observation.images.left_wrist": {
|
| 376 |
+
"dtype": "video",
|
| 377 |
+
"shape": [WRIST_VIEW_HEIGHT, WRIST_VIEW_WIDTH, 3],
|
| 378 |
+
"names": ["height", "width", "channel"],
|
| 379 |
+
},
|
| 380 |
+
"observation.images.right_wrist": {
|
| 381 |
+
"dtype": "video",
|
| 382 |
+
"shape": [WRIST_VIEW_HEIGHT, WRIST_VIEW_WIDTH, 3],
|
| 383 |
+
"names": ["height", "width", "channel"],
|
| 384 |
+
},
|
| 385 |
+
}
|
| 386 |
+
|
| 387 |
+
|
| 388 |
+
def get_wrist_camera_modality_config() -> dict:
|
| 389 |
+
"""Modality config entries for optional wrist cameras."""
|
| 390 |
+
return {
|
| 391 |
+
"video": {
|
| 392 |
+
"left_wrist": {"original_key": "observation.images.left_wrist"},
|
| 393 |
+
"right_wrist": {"original_key": "observation.images.right_wrist"},
|
| 394 |
+
},
|
| 395 |
+
}
|
| 396 |
+
|
| 397 |
+
|
| 398 |
+
def get_g1_robot_model(
|
| 399 |
+
waist_location: Literal[
|
| 400 |
+
"lower_body", "upper_body", "lower_and_upper_body"
|
| 401 |
+
] = "lower_and_upper_body",
|
| 402 |
+
high_elbow_pose: bool = False,
|
| 403 |
+
):
|
| 404 |
+
"""Instantiate the G1 + ThreeFinger RobotModel for Sonic VLA."""
|
| 405 |
+
from gear_sonic.data.robot_model.instantiation.g1 import instantiate_g1_robot_model
|
| 406 |
+
|
| 407 |
+
return instantiate_g1_robot_model(
|
| 408 |
+
waist_location=waist_location,
|
| 409 |
+
high_elbow_pose=high_elbow_pose,
|
| 410 |
+
)
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/left_ankle_pitch_link.STL
ADDED
|
Binary file (71.2 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/left_elbow_link.STL
ADDED
|
Binary file (88.8 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/left_hand_thumb_0_link.STL
ADDED
|
Binary file (8.88 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/right_ankle_pitch_link.STL
ADDED
|
Binary file (71.2 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/right_elbow_link.STL
ADDED
|
Binary file (88.8 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/right_hand_thumb_0_link.STL
ADDED
|
Binary file (8.88 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/right_wrist_pitch_link.STL
ADDED
|
Binary file (79.6 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/torso_constraint_L_rod_link.STL
ADDED
|
Binary file (74.9 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/torso_constraint_R_rod_link.STL
ADDED
|
Binary file (74.9 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/waist_roll_link.STL
ADDED
|
Binary file (24.2 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/robot_model/model_data/g1/meshes/waist_roll_link_rev_1_0.STL
ADDED
|
Binary file (85.9 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/data/video_writer.py
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import os
|
| 2 |
+
import queue
|
| 3 |
+
import sys
|
| 4 |
+
import threading
|
| 5 |
+
import time
|
| 6 |
+
|
| 7 |
+
import av
|
| 8 |
+
import numpy as np
|
| 9 |
+
|
| 10 |
+
|
| 11 |
+
class VideoWriter:
|
| 12 |
+
def __init__(
|
| 13 |
+
self,
|
| 14 |
+
output_path: str,
|
| 15 |
+
width: int,
|
| 16 |
+
height: int,
|
| 17 |
+
fps: float,
|
| 18 |
+
codec: str = "h264",
|
| 19 |
+
buffer_size: int = 50,
|
| 20 |
+
):
|
| 21 |
+
self.output_path = output_path
|
| 22 |
+
self._first_frame = True
|
| 23 |
+
|
| 24 |
+
output_dir = os.path.dirname(output_path)
|
| 25 |
+
if output_dir and not os.path.exists(output_dir):
|
| 26 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 27 |
+
|
| 28 |
+
self.queue = queue.Queue(maxsize=buffer_size)
|
| 29 |
+
self.container = av.open(output_path, mode="w")
|
| 30 |
+
self.stream = self.container.add_stream(codec, rate=fps)
|
| 31 |
+
self.stream.width = width
|
| 32 |
+
self.stream.height = height
|
| 33 |
+
thread = threading.Thread(target=self._writer_worker, daemon=True)
|
| 34 |
+
thread.start()
|
| 35 |
+
|
| 36 |
+
def _assert_dimensions(self, frame: np.ndarray) -> None:
|
| 37 |
+
assert (
|
| 38 |
+
frame.shape[1] == self.stream.width and frame.shape[0] == self.stream.height
|
| 39 |
+
), (
|
| 40 |
+
f"Incorrect frame dimensions. Input dimensions: {frame.shape[1]}x{frame.shape[0]}. "
|
| 41 |
+
f"Expected dimensions: {self.stream.width}x{self.stream.height}"
|
| 42 |
+
)
|
| 43 |
+
|
| 44 |
+
def add_frame(self, frame: np.ndarray) -> None:
|
| 45 |
+
self._assert_dimensions(frame)
|
| 46 |
+
self.queue.put(frame)
|
| 47 |
+
|
| 48 |
+
def _writer_worker(self) -> None:
|
| 49 |
+
while True:
|
| 50 |
+
frame = self.queue.get()
|
| 51 |
+
if frame is None:
|
| 52 |
+
continue
|
| 53 |
+
self._assert_dimensions(frame)
|
| 54 |
+
frame = av.VideoFrame.from_ndarray(frame, format="rgb24")
|
| 55 |
+
|
| 56 |
+
if self._first_frame:
|
| 57 |
+
stderr_fd = sys.stderr.fileno()
|
| 58 |
+
old_stderr = os.dup(stderr_fd)
|
| 59 |
+
devnull = os.open(os.devnull, os.O_WRONLY)
|
| 60 |
+
os.dup2(devnull, stderr_fd)
|
| 61 |
+
try:
|
| 62 |
+
packets = self.stream.encode(frame)
|
| 63 |
+
for packet in packets:
|
| 64 |
+
self.container.mux(packet)
|
| 65 |
+
finally:
|
| 66 |
+
os.dup2(old_stderr, stderr_fd)
|
| 67 |
+
os.close(old_stderr)
|
| 68 |
+
os.close(devnull)
|
| 69 |
+
self._first_frame = False
|
| 70 |
+
else:
|
| 71 |
+
packets = self.stream.encode(frame)
|
| 72 |
+
for packet in packets:
|
| 73 |
+
self.container.mux(packet)
|
| 74 |
+
|
| 75 |
+
def _flush_stream(self) -> None:
|
| 76 |
+
packets = self.stream.encode()
|
| 77 |
+
for packet in packets:
|
| 78 |
+
self.container.mux(packet)
|
| 79 |
+
|
| 80 |
+
def stop(self) -> str:
|
| 81 |
+
"""Blocking call. Waits for queue to drain, flushes, and closes the container."""
|
| 82 |
+
if not self.queue.empty():
|
| 83 |
+
print("Waiting for video writer queue to empty...")
|
| 84 |
+
while not self.queue.empty():
|
| 85 |
+
time.sleep(0.1)
|
| 86 |
+
|
| 87 |
+
print("Video writer queue is empty, flushing stream...")
|
| 88 |
+
self._flush_stream()
|
| 89 |
+
self.container.close()
|
| 90 |
+
return self.output_path
|
| 91 |
+
|
| 92 |
+
def cancel(self) -> None:
|
| 93 |
+
"""Immediately stops writing and deletes the output file."""
|
| 94 |
+
if os.path.exists(self.output_path):
|
| 95 |
+
os.remove(self.output_path)
|
| 96 |
+
self.container.close()
|
| 97 |
+
|
| 98 |
+
def __del__(self) -> None:
|
| 99 |
+
self.container.close()
|
GR00T-WholeBodyControl/gear_sonic/data_process/convert_soma_csv_to_motion_lib.py
ADDED
|
@@ -0,0 +1,584 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3 # noqa: EXE001
|
| 2 |
+
# ruff: noqa: T201, DOC
|
| 3 |
+
"""Convert SOMA retargeter CSV/PKL data to motion_lib format for SONIC training.
|
| 4 |
+
|
| 5 |
+
SOMA retargeter outputs G1 29-DOF motion data as CSV files (joint_pos.csv,
|
| 6 |
+
body_pos.csv, body_quat.csv) or as a joblib PKL with the same fields. This
|
| 7 |
+
script converts that data into the motion_lib PKL format expected by SONIC
|
| 8 |
+
training (root_trans_offset, pose_aa, dof, root_rot, fps).
|
| 9 |
+
|
| 10 |
+
Supports five input modes:
|
| 11 |
+
1. Single motion directory with CSVs (joint_pos.csv, body_pos.csv, body_quat.csv)
|
| 12 |
+
2. Parent directory containing multiple motion subdirectories
|
| 13 |
+
3. Deploy PKL file (joblib dict with joint_pos, body_pos_w, body_quat_w per sequence)
|
| 14 |
+
4. Directory of flat Bones-SEED CSVs (single CSV per motion, degrees+cm)
|
| 15 |
+
5. Parent directory of session dirs containing Bones-SEED CSVs
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
# Single CSV directory
|
| 19 |
+
python scripts/motion/convert_soma_csv_to_motion_lib.py \
|
| 20 |
+
--input data/soma_retarget/tired_squat_003__A360 \
|
| 21 |
+
--output data/soma_test.pkl --fps 50
|
| 22 |
+
|
| 23 |
+
# Batch: parent dir with multiple motion subdirs
|
| 24 |
+
python scripts/motion/convert_soma_csv_to_motion_lib.py \
|
| 25 |
+
--input data/soma_retarget/all_demo_4seqs \
|
| 26 |
+
--output data/soma_demo_4seqs.pkl --fps 50
|
| 27 |
+
|
| 28 |
+
# Deploy PKL file
|
| 29 |
+
python scripts/motion/convert_soma_csv_to_motion_lib.py \
|
| 30 |
+
--input data/soma_retarget/bones_test.pkl \
|
| 31 |
+
--output data/soma_bones_test.pkl --fps 50
|
| 32 |
+
|
| 33 |
+
# Bones-SEED: directory of flat CSVs (single session)
|
| 34 |
+
python scripts/motion/convert_soma_csv_to_motion_lib.py \
|
| 35 |
+
--input /path/to/bones_SEED/g1/csv/210531 \
|
| 36 |
+
--output data/bones_seed_210531.pkl --fps 50
|
| 37 |
+
|
| 38 |
+
# Bones-SEED: all sessions (parent dir)
|
| 39 |
+
python scripts/motion/convert_soma_csv_to_motion_lib.py \
|
| 40 |
+
--input /path/to/bones_SEED/g1/csv \
|
| 41 |
+
--output data/bones_seed_all.pkl --fps 50
|
| 42 |
+
"""
|
| 43 |
+
|
| 44 |
+
import argparse
|
| 45 |
+
import os
|
| 46 |
+
import sys
|
| 47 |
+
|
| 48 |
+
import joblib
|
| 49 |
+
import numpy as np
|
| 50 |
+
from scipy.spatial import transform
|
| 51 |
+
|
| 52 |
+
# IsaacLab ↔ MuJoCo joint reordering (29 DOFs for G1).
|
| 53 |
+
# MJ_TO_IL[mj] = il: for MuJoCo DOF index mj, gives the IsaacLab index il.
|
| 54 |
+
# Source: external_dependencies/SONIC_Web/demo_python.py
|
| 55 |
+
MJ_TO_IL = np.array(
|
| 56 |
+
[
|
| 57 |
+
0,
|
| 58 |
+
3,
|
| 59 |
+
6,
|
| 60 |
+
9,
|
| 61 |
+
13,
|
| 62 |
+
17,
|
| 63 |
+
1,
|
| 64 |
+
4,
|
| 65 |
+
7,
|
| 66 |
+
10,
|
| 67 |
+
14,
|
| 68 |
+
18,
|
| 69 |
+
2,
|
| 70 |
+
5,
|
| 71 |
+
8,
|
| 72 |
+
11,
|
| 73 |
+
15,
|
| 74 |
+
19,
|
| 75 |
+
21,
|
| 76 |
+
23,
|
| 77 |
+
25,
|
| 78 |
+
27,
|
| 79 |
+
12,
|
| 80 |
+
16,
|
| 81 |
+
20,
|
| 82 |
+
22,
|
| 83 |
+
24,
|
| 84 |
+
26,
|
| 85 |
+
28,
|
| 86 |
+
],
|
| 87 |
+
dtype=np.int32,
|
| 88 |
+
)
|
| 89 |
+
|
| 90 |
+
# G1 29-DOF axis definitions (from Humanoid_Batch / g1_29dof_rev_1_0.xml).
|
| 91 |
+
# Each DOF rotates around a single axis. Hardcoded to avoid torch dependency.
|
| 92 |
+
NUM_DOF = 29
|
| 93 |
+
NUM_BODIES = 30 # pelvis + 29 actuated links
|
| 94 |
+
DOF_AXIS = np.array(
|
| 95 |
+
[
|
| 96 |
+
[0, 1, 0],
|
| 97 |
+
[1, 0, 0],
|
| 98 |
+
[0, 0, 1],
|
| 99 |
+
[0, 1, 0],
|
| 100 |
+
[0, 1, 0],
|
| 101 |
+
[1, 0, 0], # left leg
|
| 102 |
+
[0, 1, 0],
|
| 103 |
+
[1, 0, 0],
|
| 104 |
+
[0, 0, 1],
|
| 105 |
+
[0, 1, 0],
|
| 106 |
+
[0, 1, 0],
|
| 107 |
+
[1, 0, 0], # right leg
|
| 108 |
+
[0, 0, 1],
|
| 109 |
+
[1, 0, 0],
|
| 110 |
+
[0, 1, 0], # waist
|
| 111 |
+
[0, 1, 0],
|
| 112 |
+
[1, 0, 0],
|
| 113 |
+
[0, 0, 1],
|
| 114 |
+
[0, 1, 0],
|
| 115 |
+
[1, 0, 0],
|
| 116 |
+
[0, 1, 0],
|
| 117 |
+
[0, 0, 1], # left arm
|
| 118 |
+
[0, 1, 0],
|
| 119 |
+
[1, 0, 0],
|
| 120 |
+
[0, 0, 1],
|
| 121 |
+
[0, 1, 0],
|
| 122 |
+
[1, 0, 0],
|
| 123 |
+
[0, 1, 0],
|
| 124 |
+
[0, 0, 1], # right arm
|
| 125 |
+
],
|
| 126 |
+
dtype=np.float32,
|
| 127 |
+
)
|
| 128 |
+
|
| 129 |
+
|
| 130 |
+
# Joint names in Bones-SEED CSV column order (after Frame + 6 root columns).
|
| 131 |
+
# These are in MuJoCo/MJCF actuator order (same as g1_29dof_rev_1_0.xml motors).
|
| 132 |
+
BONES_CSV_JOINT_NAMES = [
|
| 133 |
+
"left_hip_pitch_joint_dof",
|
| 134 |
+
"left_hip_roll_joint_dof",
|
| 135 |
+
"left_hip_yaw_joint_dof",
|
| 136 |
+
"left_knee_joint_dof",
|
| 137 |
+
"left_ankle_pitch_joint_dof",
|
| 138 |
+
"left_ankle_roll_joint_dof",
|
| 139 |
+
"right_hip_pitch_joint_dof",
|
| 140 |
+
"right_hip_roll_joint_dof",
|
| 141 |
+
"right_hip_yaw_joint_dof",
|
| 142 |
+
"right_knee_joint_dof",
|
| 143 |
+
"right_ankle_pitch_joint_dof",
|
| 144 |
+
"right_ankle_roll_joint_dof",
|
| 145 |
+
"waist_yaw_joint_dof",
|
| 146 |
+
"waist_roll_joint_dof",
|
| 147 |
+
"waist_pitch_joint_dof",
|
| 148 |
+
"left_shoulder_pitch_joint_dof",
|
| 149 |
+
"left_shoulder_roll_joint_dof",
|
| 150 |
+
"left_shoulder_yaw_joint_dof",
|
| 151 |
+
"left_elbow_joint_dof",
|
| 152 |
+
"left_wrist_roll_joint_dof",
|
| 153 |
+
"left_wrist_pitch_joint_dof",
|
| 154 |
+
"left_wrist_yaw_joint_dof",
|
| 155 |
+
"right_shoulder_pitch_joint_dof",
|
| 156 |
+
"right_shoulder_roll_joint_dof",
|
| 157 |
+
"right_shoulder_yaw_joint_dof",
|
| 158 |
+
"right_elbow_joint_dof",
|
| 159 |
+
"right_wrist_roll_joint_dof",
|
| 160 |
+
"right_wrist_pitch_joint_dof",
|
| 161 |
+
"right_wrist_yaw_joint_dof",
|
| 162 |
+
]
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
def load_bones_csv(csv_path: str) -> dict:
|
| 166 |
+
"""Load a single Bones-SEED flat CSV motion file.
|
| 167 |
+
|
| 168 |
+
Bones-SEED CSV format: Frame, root_translate{X,Y,Z}, root_rotate{X,Y,Z}, 29 joint DOFs.
|
| 169 |
+
All angles in degrees, positions in centimeters.
|
| 170 |
+
"""
|
| 171 |
+
import pandas as pd
|
| 172 |
+
|
| 173 |
+
data = pd.read_csv(csv_path)
|
| 174 |
+
T = len(data)
|
| 175 |
+
|
| 176 |
+
# Root position: cm → meters
|
| 177 |
+
root_pos = (
|
| 178 |
+
np.stack(
|
| 179 |
+
[
|
| 180 |
+
data["root_translateX"].values, # noqa: PD011
|
| 181 |
+
data["root_translateY"].values, # noqa: PD011
|
| 182 |
+
data["root_translateZ"].values, # noqa: PD011
|
| 183 |
+
],
|
| 184 |
+
axis=1,
|
| 185 |
+
).astype(np.float32)
|
| 186 |
+
/ 100.0
|
| 187 |
+
) # cm → m
|
| 188 |
+
|
| 189 |
+
# Root rotation: Euler xyz (intrinsic) degrees → quaternion (xyzw scipy convention)
|
| 190 |
+
# Reference: gear_sonic/data_process/process_bones_to_motionlib.py uses "xyz" (intrinsic)
|
| 191 |
+
euler_deg = np.stack(
|
| 192 |
+
[
|
| 193 |
+
data["root_rotateX"].values, # noqa: PD011
|
| 194 |
+
data["root_rotateY"].values, # noqa: PD011
|
| 195 |
+
data["root_rotateZ"].values, # noqa: PD011
|
| 196 |
+
],
|
| 197 |
+
axis=1,
|
| 198 |
+
).astype(np.float64)
|
| 199 |
+
root_quat_xyzw = (
|
| 200 |
+
transform.Rotation.from_euler("xyz", euler_deg, degrees=True).as_quat().astype(np.float32)
|
| 201 |
+
)
|
| 202 |
+
# Convert xyzw → wxyz for body_quat_w format
|
| 203 |
+
root_quat_wxyz = root_quat_xyzw[:, [3, 0, 1, 2]]
|
| 204 |
+
|
| 205 |
+
# Joint DOFs: degrees → radians, already in MuJoCo/MJCF actuator order
|
| 206 |
+
joint_cols = [c for c in data.columns if c.endswith("_dof")]
|
| 207 |
+
joint_pos_mj = np.deg2rad(data[joint_cols].values).astype(np.float32) # (T, 29)
|
| 208 |
+
|
| 209 |
+
# Create dummy body_pos_w and body_quat_w (only root body populated, rest zeros)
|
| 210 |
+
# The converter only uses body_pos_w[:,0] for root_trans and body_quat_w[:,0] for root_rot
|
| 211 |
+
body_pos_w = np.zeros((T, 14, 3), dtype=np.float32)
|
| 212 |
+
body_pos_w[:, 0, :] = root_pos
|
| 213 |
+
body_quat_w = np.zeros((T, 14, 4), dtype=np.float32)
|
| 214 |
+
body_quat_w[:, :, 0] = 1.0 # identity quaternion wxyz
|
| 215 |
+
body_quat_w[:, 0, :] = root_quat_wxyz
|
| 216 |
+
|
| 217 |
+
return {
|
| 218 |
+
"joint_pos": joint_pos_mj, # (T, 29) MuJoCo order, radians
|
| 219 |
+
"body_pos_w": body_pos_w, # (T, 14, 3)
|
| 220 |
+
"body_quat_w": body_quat_w, # (T, 14, 4) wxyz
|
| 221 |
+
"joint_order": "mj", # already in MuJoCo order, skip IL→MJ reorder
|
| 222 |
+
}
|
| 223 |
+
|
| 224 |
+
|
| 225 |
+
def load_csv_motion(motion_dir: str) -> dict:
|
| 226 |
+
"""Load a single motion from a directory of CSV files."""
|
| 227 |
+
joint_pos_f = os.path.join(motion_dir, "joint_pos.csv")
|
| 228 |
+
body_pos_f = os.path.join(motion_dir, "body_pos.csv")
|
| 229 |
+
body_quat_f = os.path.join(motion_dir, "body_quat.csv")
|
| 230 |
+
|
| 231 |
+
if not os.path.exists(joint_pos_f):
|
| 232 |
+
return None
|
| 233 |
+
|
| 234 |
+
joint_pos = np.loadtxt(joint_pos_f, delimiter=",", skiprows=1, dtype=np.float32)
|
| 235 |
+
body_pos = np.loadtxt(body_pos_f, delimiter=",", skiprows=1, dtype=np.float32)
|
| 236 |
+
body_quat = np.loadtxt(body_quat_f, delimiter=",", skiprows=1, dtype=np.float32)
|
| 237 |
+
|
| 238 |
+
# Reshape body data: (T, 14*3) → (T, 14, 3), (T, 14*4) → (T, 14, 4)
|
| 239 |
+
T = joint_pos.shape[0]
|
| 240 |
+
body_pos = body_pos.reshape(T, -1, 3)
|
| 241 |
+
body_quat = body_quat.reshape(T, -1, 4)
|
| 242 |
+
|
| 243 |
+
return {
|
| 244 |
+
"joint_pos": joint_pos, # (T, 29) IsaacLab order
|
| 245 |
+
"body_pos_w": body_pos, # (T, 14, 3) world frame
|
| 246 |
+
"body_quat_w": body_quat, # (T, 14, 4) wxyz format
|
| 247 |
+
}
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
def convert_sequence(seq_data: dict, fps: int, humanoid_fk=None) -> dict: # noqa: ARG001
|
| 251 |
+
"""Convert a single deploy-format sequence to motion_lib format.
|
| 252 |
+
|
| 253 |
+
Args:
|
| 254 |
+
seq_data: dict with joint_pos (T, 29), body_pos_w (T, 14, 3),
|
| 255 |
+
body_quat_w (T, 14, 4 wxyz)
|
| 256 |
+
fps: frame rate of the input data
|
| 257 |
+
humanoid_fk: Optional Humanoid_Batch instance (unused, kept for compat)
|
| 258 |
+
|
| 259 |
+
Returns:
|
| 260 |
+
motion_lib entry dict with root_trans_offset, pose_aa, dof, root_rot, fps
|
| 261 |
+
"""
|
| 262 |
+
joint_pos = seq_data["joint_pos"] # (T, 29)
|
| 263 |
+
body_pos_w = seq_data["body_pos_w"] # (T, 14, 3)
|
| 264 |
+
body_quat_w = seq_data["body_quat_w"] # (T, 14, 4) wxyz
|
| 265 |
+
joint_order = seq_data.get("joint_order", "il") # "il" or "mj"
|
| 266 |
+
|
| 267 |
+
T = joint_pos.shape[0]
|
| 268 |
+
|
| 269 |
+
# 1. Root position: body_0 (pelvis) position
|
| 270 |
+
root_trans_offset = body_pos_w[:, 0, :].copy() # (T, 3)
|
| 271 |
+
|
| 272 |
+
# 2. Root quaternion: body_0 quaternion, convert wxyz → xyzw (scipy convention)
|
| 273 |
+
root_quat_wxyz = body_quat_w[:, 0, :] # (T, 4) [w, x, y, z]
|
| 274 |
+
root_quat_xyzw = root_quat_wxyz[:, [1, 2, 3, 0]] # (T, 4) [x, y, z, w]
|
| 275 |
+
|
| 276 |
+
# 3. Reorder DOFs to MuJoCo order if needed
|
| 277 |
+
if joint_order == "il":
|
| 278 |
+
# Input is IsaacLab order → reorder to MuJoCo (MJCF actuator order)
|
| 279 |
+
dof_mj = joint_pos[:, MJ_TO_IL] # (T, 29)
|
| 280 |
+
else:
|
| 281 |
+
# Input is already in MuJoCo order (e.g., Bones-SEED CSVs)
|
| 282 |
+
dof_mj = joint_pos # (T, 29)
|
| 283 |
+
|
| 284 |
+
# 4. Convert DOF → pose_aa using hardcoded G1 axis definitions
|
| 285 |
+
dof = dof_mj[:, :NUM_DOF]
|
| 286 |
+
|
| 287 |
+
# pose_aa[body_idx] = dof_axis * dof_value (axis-angle representation)
|
| 288 |
+
# Body 0 = pelvis (root), bodies 1-29 = actuated joints
|
| 289 |
+
pose_aa = np.zeros((T, NUM_BODIES, 3), dtype=np.float32)
|
| 290 |
+
# Actuated joints: body idx = dof idx + 1
|
| 291 |
+
pose_aa[:, 1:NUM_BODIES, :] = DOF_AXIS[None, :, :] * dof[:, :, None]
|
| 292 |
+
|
| 293 |
+
# Set root rotation as axis-angle
|
| 294 |
+
pose_aa[:, 0, :] = transform.Rotation.from_quat(root_quat_xyzw).as_rotvec()
|
| 295 |
+
|
| 296 |
+
return {
|
| 297 |
+
"root_trans_offset": root_trans_offset.astype(np.float32),
|
| 298 |
+
"pose_aa": pose_aa.astype(np.float32),
|
| 299 |
+
"dof": dof.astype(np.float32),
|
| 300 |
+
"root_rot": root_quat_xyzw.astype(np.float32), # xyzw (scipy convention)
|
| 301 |
+
"smpl_joints": np.zeros((T, 24, 3), dtype=np.float32), # placeholder
|
| 302 |
+
"fps": fps,
|
| 303 |
+
}
|
| 304 |
+
|
| 305 |
+
|
| 306 |
+
def downsample_sequence(entry: dict, fps_source: int, fps_target: int) -> dict:
|
| 307 |
+
"""Downsample a motion_lib entry using stride-based frame skipping.
|
| 308 |
+
|
| 309 |
+
Matches process_bones_to_motionlib.py: jump = int(fps_source / fps_target).
|
| 310 |
+
Best used when fps_source is an exact multiple of fps_target (e.g. 120→30).
|
| 311 |
+
The resulting PKL is stored at fps_target; fk_batch handles the final
|
| 312 |
+
resampling to target_fps at load time using the canonical interploate_pose formula.
|
| 313 |
+
"""
|
| 314 |
+
if fps_source == fps_target:
|
| 315 |
+
return entry
|
| 316 |
+
jump = int(fps_source / fps_target)
|
| 317 |
+
if jump <= 1:
|
| 318 |
+
return entry
|
| 319 |
+
return {
|
| 320 |
+
"root_trans_offset": entry["root_trans_offset"][::jump],
|
| 321 |
+
"pose_aa": entry["pose_aa"][::jump],
|
| 322 |
+
"dof": entry["dof"][::jump],
|
| 323 |
+
"root_rot": entry["root_rot"][::jump],
|
| 324 |
+
"smpl_joints": entry["smpl_joints"][::jump],
|
| 325 |
+
"fps": fps_target,
|
| 326 |
+
}
|
| 327 |
+
|
| 328 |
+
|
| 329 |
+
def init_humanoid_fk():
|
| 330 |
+
"""Initialize Humanoid_Batch from the G1 MJCF config.
|
| 331 |
+
|
| 332 |
+
Only needed for non-Bones-SEED inputs (deploy PKL, SOMA CSV dirs).
|
| 333 |
+
Bones-SEED path uses hardcoded DOF_AXIS constants instead.
|
| 334 |
+
"""
|
| 335 |
+
import omegaconf
|
| 336 |
+
|
| 337 |
+
motion_cfg = omegaconf.OmegaConf.create(
|
| 338 |
+
{
|
| 339 |
+
"asset": {
|
| 340 |
+
"assetRoot": "gear_sonic/data/assets/robot_description/mjcf/",
|
| 341 |
+
"assetFileName": "g1_29dof_rev_1_0.xml",
|
| 342 |
+
"urdfFileName": "",
|
| 343 |
+
},
|
| 344 |
+
"extend_config": [],
|
| 345 |
+
}
|
| 346 |
+
)
|
| 347 |
+
from gear_sonic.utils.motion_lib import torch_humanoid_batch
|
| 348 |
+
|
| 349 |
+
return torch_humanoid_batch.Humanoid_Batch(motion_cfg)
|
| 350 |
+
|
| 351 |
+
|
| 352 |
+
def process_session_csvs(args_tuple):
|
| 353 |
+
"""Process all CSVs in a single session directory. Used by multiprocessing."""
|
| 354 |
+
session_dir, session_name, out_dir, fps, fps_source = args_tuple
|
| 355 |
+
import warnings
|
| 356 |
+
|
| 357 |
+
warnings.filterwarnings("ignore")
|
| 358 |
+
|
| 359 |
+
csv_files = sorted([f for f in os.listdir(session_dir) if f.endswith(".csv")])
|
| 360 |
+
|
| 361 |
+
session_out = os.path.join(out_dir, session_name)
|
| 362 |
+
os.makedirs(session_out, exist_ok=True)
|
| 363 |
+
|
| 364 |
+
converted = 0
|
| 365 |
+
failed = 0
|
| 366 |
+
for csv_f in csv_files:
|
| 367 |
+
name = os.path.splitext(csv_f)[0]
|
| 368 |
+
out_path = os.path.join(session_out, name + ".pkl")
|
| 369 |
+
if os.path.exists(out_path):
|
| 370 |
+
converted += 1 # skip existing
|
| 371 |
+
continue
|
| 372 |
+
try:
|
| 373 |
+
seq = load_bones_csv(os.path.join(session_dir, csv_f))
|
| 374 |
+
fps_for_convert = fps_source if fps_source else fps
|
| 375 |
+
entry = convert_sequence(seq, fps_for_convert)
|
| 376 |
+
if fps_source and fps_source != fps:
|
| 377 |
+
entry = downsample_sequence(entry, fps_source, fps)
|
| 378 |
+
joblib.dump({name: entry}, out_path, compress=True)
|
| 379 |
+
converted += 1
|
| 380 |
+
except Exception: # noqa: BLE001
|
| 381 |
+
failed += 1
|
| 382 |
+
return session_name, converted, failed, len(csv_files)
|
| 383 |
+
|
| 384 |
+
|
| 385 |
+
def main():
|
| 386 |
+
parser = argparse.ArgumentParser(description="Convert SOMA CSV/PKL to motion_lib format")
|
| 387 |
+
parser.add_argument(
|
| 388 |
+
"--input", required=True, help="CSV dir, parent dir of CSV dirs, or deploy PKL"
|
| 389 |
+
)
|
| 390 |
+
parser.add_argument(
|
| 391 |
+
"--output", required=True, help="Output path (PKL file or directory for individual PKLs)"
|
| 392 |
+
)
|
| 393 |
+
parser.add_argument(
|
| 394 |
+
"--fps",
|
| 395 |
+
type=int,
|
| 396 |
+
default=30,
|
| 397 |
+
help="Target output FPS (default: 30, matches process_bones_to_motionlib)",
|
| 398 |
+
)
|
| 399 |
+
parser.add_argument(
|
| 400 |
+
"--fps_source",
|
| 401 |
+
type=int,
|
| 402 |
+
default=None,
|
| 403 |
+
help="Source data FPS. If set and != --fps, data is downsampled. "
|
| 404 |
+
"Bones-SEED CSVs are typically 120fps.",
|
| 405 |
+
)
|
| 406 |
+
parser.add_argument(
|
| 407 |
+
"--individual",
|
| 408 |
+
action="store_true",
|
| 409 |
+
help="Write individual PKLs per motion (preserves session dir structure)",
|
| 410 |
+
)
|
| 411 |
+
parser.add_argument(
|
| 412 |
+
"--num_workers",
|
| 413 |
+
type=int,
|
| 414 |
+
default=8,
|
| 415 |
+
help="Number of parallel workers for --individual mode",
|
| 416 |
+
)
|
| 417 |
+
args = parser.parse_args()
|
| 418 |
+
|
| 419 |
+
print(f"G1 {NUM_DOF} DOFs, {NUM_BODIES} bodies (hardcoded axes)")
|
| 420 |
+
|
| 421 |
+
# Individual PKL mode: skip scanning, go straight to parallel per-session processing
|
| 422 |
+
if args.individual:
|
| 423 |
+
if not os.path.isdir(args.input):
|
| 424 |
+
print("ERROR: --individual requires a directory input")
|
| 425 |
+
sys.exit(1)
|
| 426 |
+
|
| 427 |
+
# Detect: is input a single session dir (contains CSVs) or parent of sessions?
|
| 428 |
+
has_csvs = any(f.endswith(".csv") for f in os.listdir(args.input))
|
| 429 |
+
subdirs = sorted(
|
| 430 |
+
[d for d in os.listdir(args.input) if os.path.isdir(os.path.join(args.input, d))]
|
| 431 |
+
)
|
| 432 |
+
has_session_subdirs = (
|
| 433 |
+
any(
|
| 434 |
+
any(f.endswith(".csv") for f in os.listdir(os.path.join(args.input, d)))
|
| 435 |
+
for d in subdirs[:3]
|
| 436 |
+
)
|
| 437 |
+
if subdirs
|
| 438 |
+
else False
|
| 439 |
+
)
|
| 440 |
+
|
| 441 |
+
session_dirs = []
|
| 442 |
+
if has_session_subdirs:
|
| 443 |
+
for d in subdirs:
|
| 444 |
+
subdir = os.path.join(args.input, d)
|
| 445 |
+
if any(f.endswith(".csv") for f in os.listdir(subdir)):
|
| 446 |
+
session_dirs.append((subdir, d, args.output, args.fps, args.fps_source))
|
| 447 |
+
elif has_csvs:
|
| 448 |
+
session_name = os.path.basename(args.input.rstrip("/"))
|
| 449 |
+
session_dirs.append((args.input, session_name, args.output, args.fps, args.fps_source))
|
| 450 |
+
|
| 451 |
+
print(f"\nBatch converting {len(session_dirs)} sessions with {args.num_workers} workers")
|
| 452 |
+
print(f"Output: {args.output}")
|
| 453 |
+
os.makedirs(args.output, exist_ok=True)
|
| 454 |
+
|
| 455 |
+
import multiprocessing
|
| 456 |
+
|
| 457 |
+
total_converted = 0
|
| 458 |
+
total_failed = 0
|
| 459 |
+
total_csvs = 0
|
| 460 |
+
with multiprocessing.Pool(processes=args.num_workers) as pool:
|
| 461 |
+
for session_name, converted, failed, n_csvs in pool.imap_unordered(
|
| 462 |
+
process_session_csvs, session_dirs
|
| 463 |
+
):
|
| 464 |
+
total_converted += converted
|
| 465 |
+
total_failed += failed
|
| 466 |
+
total_csvs += n_csvs
|
| 467 |
+
print(
|
| 468 |
+
f" {session_name}: {converted}/{n_csvs} converted"
|
| 469 |
+
+ (f" ({failed} failed)" if failed else "")
|
| 470 |
+
)
|
| 471 |
+
|
| 472 |
+
print(
|
| 473 |
+
f"\nDone: {total_converted} motions converted, {total_failed} failed, {total_csvs} total CSVs"
|
| 474 |
+
)
|
| 475 |
+
return
|
| 476 |
+
|
| 477 |
+
# Detect input mode (combined PKL output path)
|
| 478 |
+
sequences = {}
|
| 479 |
+
|
| 480 |
+
if args.input.endswith(".pkl"):
|
| 481 |
+
# Mode 3: Deploy PKL file
|
| 482 |
+
print(f"Loading deploy PKL: {args.input}")
|
| 483 |
+
data = joblib.load(args.input)
|
| 484 |
+
for name, seq in data.items():
|
| 485 |
+
sequences[name] = seq
|
| 486 |
+
print(f" Found {len(sequences)} sequences")
|
| 487 |
+
|
| 488 |
+
elif os.path.isfile(os.path.join(args.input, "joint_pos.csv")):
|
| 489 |
+
# Mode 1: Single CSV directory
|
| 490 |
+
name = os.path.basename(args.input)
|
| 491 |
+
print(f"Loading single CSV motion: {name}")
|
| 492 |
+
seq = load_csv_motion(args.input)
|
| 493 |
+
if seq is None:
|
| 494 |
+
print("ERROR: joint_pos.csv not found")
|
| 495 |
+
sys.exit(1)
|
| 496 |
+
sequences[name] = seq
|
| 497 |
+
print(f" {seq['joint_pos'].shape[0]} frames")
|
| 498 |
+
|
| 499 |
+
elif os.path.isdir(args.input):
|
| 500 |
+
# Check if directory contains flat CSVs (Bones-SEED format)
|
| 501 |
+
csv_files = sorted([f for f in os.listdir(args.input) if f.endswith(".csv")])
|
| 502 |
+
subdirs = sorted(
|
| 503 |
+
[d for d in os.listdir(args.input) if os.path.isdir(os.path.join(args.input, d))]
|
| 504 |
+
)
|
| 505 |
+
|
| 506 |
+
if csv_files and not any(
|
| 507 |
+
os.path.exists(os.path.join(args.input, d, "joint_pos.csv"))
|
| 508 |
+
for d in subdirs[:5] # check first 5 subdirs
|
| 509 |
+
):
|
| 510 |
+
# Mode 4: Directory of flat Bones-SEED CSVs
|
| 511 |
+
print(f"Scanning directory for Bones-SEED CSVs: {args.input}")
|
| 512 |
+
for csv_f in csv_files:
|
| 513 |
+
csv_path = os.path.join(args.input, csv_f)
|
| 514 |
+
name = os.path.splitext(csv_f)[0]
|
| 515 |
+
try:
|
| 516 |
+
seq = load_bones_csv(csv_path)
|
| 517 |
+
sequences[name] = seq
|
| 518 |
+
except Exception as e: # noqa: BLE001
|
| 519 |
+
print(f" WARNING: Failed to load {csv_f}: {e}")
|
| 520 |
+
print(f" Found {len(sequences)} Bones-SEED CSV motions")
|
| 521 |
+
elif subdirs:
|
| 522 |
+
# Check if subdirs contain flat CSVs (batch of session dirs)
|
| 523 |
+
has_session_csvs = False
|
| 524 |
+
for dname in subdirs[:3]:
|
| 525 |
+
subdir = os.path.join(args.input, dname)
|
| 526 |
+
sub_csvs = [f for f in os.listdir(subdir) if f.endswith(".csv")]
|
| 527 |
+
if sub_csvs and not os.path.exists(os.path.join(subdir, "joint_pos.csv")):
|
| 528 |
+
has_session_csvs = True
|
| 529 |
+
break
|
| 530 |
+
|
| 531 |
+
if has_session_csvs:
|
| 532 |
+
# Mode 5: Parent dir of session dirs containing Bones-SEED CSVs
|
| 533 |
+
print(f"Scanning session directories for Bones-SEED CSVs: {args.input}")
|
| 534 |
+
for dname in sorted(subdirs):
|
| 535 |
+
subdir = os.path.join(args.input, dname)
|
| 536 |
+
sub_csvs = sorted([f for f in os.listdir(subdir) if f.endswith(".csv")])
|
| 537 |
+
for csv_f in sub_csvs:
|
| 538 |
+
csv_path = os.path.join(subdir, csv_f)
|
| 539 |
+
name = os.path.splitext(csv_f)[0]
|
| 540 |
+
try:
|
| 541 |
+
seq = load_bones_csv(csv_path)
|
| 542 |
+
sequences[name] = seq
|
| 543 |
+
except Exception as e: # noqa: BLE001
|
| 544 |
+
print(f" WARNING: Failed to load {dname}/{csv_f}: {e}")
|
| 545 |
+
if sub_csvs:
|
| 546 |
+
print(f" Session {dname}: {len(sub_csvs)} CSVs")
|
| 547 |
+
print(f" Found {len(sequences)} total Bones-SEED CSV motions")
|
| 548 |
+
else:
|
| 549 |
+
# Mode 2: Parent directory with SOMA-style subdirectories
|
| 550 |
+
print(f"Scanning directory: {args.input}")
|
| 551 |
+
for dname in sorted(subdirs):
|
| 552 |
+
subdir = os.path.join(args.input, dname)
|
| 553 |
+
seq = load_csv_motion(subdir)
|
| 554 |
+
if seq is not None:
|
| 555 |
+
sequences[dname] = seq
|
| 556 |
+
print(f" Found {len(sequences)} motion directories with CSVs")
|
| 557 |
+
else:
|
| 558 |
+
print(f"ERROR: {args.input} is not a valid input")
|
| 559 |
+
sys.exit(1)
|
| 560 |
+
|
| 561 |
+
if not sequences:
|
| 562 |
+
print("ERROR: No sequences found")
|
| 563 |
+
sys.exit(1)
|
| 564 |
+
|
| 565 |
+
# Convert each sequence (combined PKL mode)
|
| 566 |
+
motion_lib_dict = {}
|
| 567 |
+
for name, seq_data in sequences.items():
|
| 568 |
+
T = seq_data["joint_pos"].shape[0]
|
| 569 |
+
print(f" Converting {name}: {T} frames @ {args.fps} fps")
|
| 570 |
+
fps_for_convert = args.fps_source if args.fps_source else args.fps
|
| 571 |
+
entry = convert_sequence(seq_data, fps_for_convert)
|
| 572 |
+
if args.fps_source and args.fps_source != args.fps:
|
| 573 |
+
entry = downsample_sequence(entry, args.fps_source, args.fps)
|
| 574 |
+
motion_lib_dict[name] = entry
|
| 575 |
+
|
| 576 |
+
# Save
|
| 577 |
+
os.makedirs(os.path.dirname(os.path.abspath(args.output)), exist_ok=True)
|
| 578 |
+
print(f"\nSaving motion_lib PKL: {args.output}")
|
| 579 |
+
joblib.dump(motion_lib_dict, args.output, compress=True)
|
| 580 |
+
print(f"Done: {len(motion_lib_dict)} sequences saved")
|
| 581 |
+
|
| 582 |
+
|
| 583 |
+
if __name__ == "__main__":
|
| 584 |
+
main()
|
GR00T-WholeBodyControl/gear_sonic/data_process/extract_soma_joints_from_bvh.py
ADDED
|
@@ -0,0 +1,394 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
# ruff: noqa: T201, BLE001, DOC
|
| 3 |
+
"""Extract SOMA skeleton FK joint positions from BVH files.
|
| 4 |
+
|
| 5 |
+
Parses NOVA-skeleton BVH files and computes forward kinematics to extract
|
| 6 |
+
world-space 3D joint positions for a selected 26-joint subset. Outputs
|
| 7 |
+
per-motion PKL files in the same directory structure as the robot PKLs.
|
| 8 |
+
|
| 9 |
+
The 26 selected joints cover the major body landmarks with symmetric arms
|
| 10 |
+
(including Thumb1 + Middle1 per hand for orientation): hips, spine chain,
|
| 11 |
+
shoulders, arms, hands+fingers, legs, feet.
|
| 12 |
+
|
| 13 |
+
Input: BVH files from bones_update_240924/anims_uniform_novaskel_v1/BVH/
|
| 14 |
+
Output: Per-motion PKL files with soma_joints (T, 26, 3) Z-up meters body-local,
|
| 15 |
+
soma_root_quat (T, 4) wxyz Y-up BVH world rotation
|
| 16 |
+
|
| 17 |
+
Usage:
|
| 18 |
+
# Single session
|
| 19 |
+
python scripts/motion/extract_soma_joints_from_bvh.py \
|
| 20 |
+
--input /path/to/novaskel_v1/BVH/210531 \
|
| 21 |
+
--output /path/to/output/bones_soma_joints/210531 \
|
| 22 |
+
--fps 30
|
| 23 |
+
|
| 24 |
+
# All sessions (parent dir)
|
| 25 |
+
python scripts/motion/extract_soma_joints_from_bvh.py \
|
| 26 |
+
--input /path/to/novaskel_v1/BVH \
|
| 27 |
+
--output /path/to/output/bones_soma_joints \
|
| 28 |
+
--fps 30 --num_workers 8
|
| 29 |
+
"""
|
| 30 |
+
|
| 31 |
+
import argparse
|
| 32 |
+
import glob
|
| 33 |
+
import multiprocessing
|
| 34 |
+
import os
|
| 35 |
+
import os.path as osp
|
| 36 |
+
import re
|
| 37 |
+
import sys
|
| 38 |
+
import time
|
| 39 |
+
|
| 40 |
+
import joblib
|
| 41 |
+
import numpy as np
|
| 42 |
+
from scipy.spatial import transform
|
| 43 |
+
|
| 44 |
+
# 26-joint subset of the 78-joint NOVA skeleton (Root excluded).
|
| 45 |
+
# Covers major body landmarks, excluding most fingers, face details, end sites.
|
| 46 |
+
# Arms are fully symmetric with two finger joints per hand (Thumb1 + Middle1)
|
| 47 |
+
# to determine hand orientation.
|
| 48 |
+
SOMA_JOINTS = [
|
| 49 |
+
"Hips", # 0 - pelvis
|
| 50 |
+
"Spine1", # 1 - lower spine
|
| 51 |
+
"Spine2", # 2 - mid spine
|
| 52 |
+
"Chest", # 3 - upper spine
|
| 53 |
+
"Neck1", # 4 - neck
|
| 54 |
+
"Head", # 5 - head
|
| 55 |
+
"LeftShoulder", # 6 - left clavicle
|
| 56 |
+
"LeftArm", # 7 - left upper arm
|
| 57 |
+
"LeftForeArm", # 8 - left elbow
|
| 58 |
+
"LeftHand", # 9 - left wrist
|
| 59 |
+
"LeftHandThumb1", # 10 - left thumb (hand orientation)
|
| 60 |
+
"LeftHandMiddle1", # 11 - left middle finger (hand orientation)
|
| 61 |
+
"RightShoulder", # 12 - right clavicle
|
| 62 |
+
"RightArm", # 13 - right upper arm
|
| 63 |
+
"RightForeArm", # 14 - right elbow
|
| 64 |
+
"RightHand", # 15 - right wrist
|
| 65 |
+
"RightHandThumb1", # 16 - right thumb (hand orientation)
|
| 66 |
+
"RightHandMiddle1", # 17 - right middle finger (hand orientation)
|
| 67 |
+
"LeftLeg", # 18 - left hip / upper leg
|
| 68 |
+
"LeftShin", # 19 - left knee
|
| 69 |
+
"LeftFoot", # 20 - left ankle
|
| 70 |
+
"LeftToeBase", # 21 - left toe
|
| 71 |
+
"RightLeg", # 22 - right hip / upper leg
|
| 72 |
+
"RightShin", # 23 - right knee
|
| 73 |
+
"RightFoot", # 24 - right ankle
|
| 74 |
+
"RightToeBase", # 25 - right toe
|
| 75 |
+
]
|
| 76 |
+
|
| 77 |
+
NUM_SOMA_JOINTS = len(SOMA_JOINTS)
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
def parse_bvh(filepath):
|
| 81 |
+
"""Parse BVH hierarchy and motion data.
|
| 82 |
+
|
| 83 |
+
Returns:
|
| 84 |
+
joints: list of dicts with name, offset, channels, parent_idx
|
| 85 |
+
channel_order: list of (joint_idx, channel_name) tuples
|
| 86 |
+
motion_data: (n_frames, n_channels) numpy array
|
| 87 |
+
n_frames: int
|
| 88 |
+
frame_time: float (seconds per frame)
|
| 89 |
+
"""
|
| 90 |
+
with open(filepath) as f:
|
| 91 |
+
lines = f.readlines()
|
| 92 |
+
|
| 93 |
+
joints = []
|
| 94 |
+
joint_stack = []
|
| 95 |
+
channel_order = []
|
| 96 |
+
i = 0
|
| 97 |
+
|
| 98 |
+
while i < len(lines):
|
| 99 |
+
line = lines[i].strip()
|
| 100 |
+
if line == "MOTION":
|
| 101 |
+
i += 1
|
| 102 |
+
break
|
| 103 |
+
|
| 104 |
+
m = re.match(r"(ROOT|JOINT)\s+(\S+)", line)
|
| 105 |
+
if m:
|
| 106 |
+
name = m.group(2)
|
| 107 |
+
parent_idx = joint_stack[-1] if joint_stack else -1
|
| 108 |
+
joints.append({"name": name, "offset": None, "channels": [], "parent_idx": parent_idx})
|
| 109 |
+
joint_stack.append(len(joints) - 1)
|
| 110 |
+
elif line.startswith("OFFSET") and joint_stack:
|
| 111 |
+
vals = [float(x) for x in line.split()[1:]]
|
| 112 |
+
joints[joint_stack[-1]]["offset"] = np.array(vals)
|
| 113 |
+
elif line.startswith("CHANNELS") and joint_stack:
|
| 114 |
+
parts = line.split()
|
| 115 |
+
n_ch = int(parts[1])
|
| 116 |
+
ch_names = parts[2 : 2 + n_ch]
|
| 117 |
+
joints[joint_stack[-1]]["channels"] = ch_names
|
| 118 |
+
for ch in ch_names:
|
| 119 |
+
channel_order.append((joint_stack[-1], ch))
|
| 120 |
+
elif line == "}":
|
| 121 |
+
if joint_stack:
|
| 122 |
+
joint_stack.pop()
|
| 123 |
+
i += 1
|
| 124 |
+
|
| 125 |
+
# Parse MOTION section
|
| 126 |
+
frames_line = lines[i].strip()
|
| 127 |
+
n_frames = int(frames_line.split(":")[1])
|
| 128 |
+
i += 1
|
| 129 |
+
frame_time = float(lines[i].strip().split(":")[1])
|
| 130 |
+
i += 1
|
| 131 |
+
|
| 132 |
+
motion_data = np.empty((n_frames, len(channel_order)))
|
| 133 |
+
for f_idx in range(n_frames):
|
| 134 |
+
vals = lines[i].strip().split()
|
| 135 |
+
motion_data[f_idx] = [float(v) for v in vals]
|
| 136 |
+
i += 1
|
| 137 |
+
|
| 138 |
+
return joints, channel_order, motion_data, n_frames, frame_time
|
| 139 |
+
|
| 140 |
+
|
| 141 |
+
def compute_fk_selected(joints, channel_order, motion_data, selected_names):
|
| 142 |
+
"""Compute FK world positions for selected joints only.
|
| 143 |
+
|
| 144 |
+
Uses vectorized rotation computation per joint across all frames.
|
| 145 |
+
|
| 146 |
+
Args:
|
| 147 |
+
joints: parsed joint hierarchy
|
| 148 |
+
channel_order: channel mapping
|
| 149 |
+
motion_data: (n_frames, n_channels)
|
| 150 |
+
selected_names: list of joint names to extract
|
| 151 |
+
|
| 152 |
+
Returns:
|
| 153 |
+
selected_positions: (n_frames, len(selected_names), 3) in BVH units (cm)
|
| 154 |
+
root_quats: (n_frames, 4) root orientation quaternions (xyzw)
|
| 155 |
+
"""
|
| 156 |
+
n_frames = motion_data.shape[0]
|
| 157 |
+
n_joints = len(joints)
|
| 158 |
+
joint_names = [j["name"] for j in joints]
|
| 159 |
+
|
| 160 |
+
# Build selected indices
|
| 161 |
+
selected_indices = set()
|
| 162 |
+
for name in selected_names:
|
| 163 |
+
if name in joint_names:
|
| 164 |
+
selected_indices.add(joint_names.index(name))
|
| 165 |
+
|
| 166 |
+
# Also include all ancestors needed for FK
|
| 167 |
+
ancestors = set()
|
| 168 |
+
for idx in selected_indices:
|
| 169 |
+
j = idx
|
| 170 |
+
while j >= 0:
|
| 171 |
+
ancestors.add(j)
|
| 172 |
+
j = joints[j]["parent_idx"]
|
| 173 |
+
compute_joints = sorted(ancestors | selected_indices)
|
| 174 |
+
|
| 175 |
+
# Pre-compute per-joint channel indices
|
| 176 |
+
joint_channels = {j: [] for j in range(n_joints)}
|
| 177 |
+
for ch_idx, (j_idx, ch_name) in enumerate(channel_order):
|
| 178 |
+
joint_channels[j_idx].append((ch_idx, ch_name))
|
| 179 |
+
|
| 180 |
+
# Compute FK for all frames
|
| 181 |
+
world_rots = np.zeros((n_frames, n_joints, 3, 3))
|
| 182 |
+
world_pos = np.zeros((n_frames, n_joints, 3))
|
| 183 |
+
|
| 184 |
+
for j_idx in compute_joints:
|
| 185 |
+
joint = joints[j_idx]
|
| 186 |
+
offset = joint["offset"] if joint["offset"] is not None else np.zeros(3)
|
| 187 |
+
|
| 188 |
+
# Extract position and rotation channels
|
| 189 |
+
pos_channels = {}
|
| 190 |
+
rot_order = ""
|
| 191 |
+
rot_ch_indices = []
|
| 192 |
+
for ch_idx, ch_name in joint_channels[j_idx]:
|
| 193 |
+
if ch_name.endswith("position"):
|
| 194 |
+
pos_channels[ch_name] = ch_idx
|
| 195 |
+
elif ch_name.endswith("rotation"):
|
| 196 |
+
rot_order += ch_name[0].lower()
|
| 197 |
+
rot_ch_indices.append(ch_idx)
|
| 198 |
+
|
| 199 |
+
# Local position (all frames)
|
| 200 |
+
has_pos_channels = bool(pos_channels)
|
| 201 |
+
if has_pos_channels:
|
| 202 |
+
# Joints with position channels: use channels directly (not additive to offset)
|
| 203 |
+
local_pos = np.zeros((n_frames, 3))
|
| 204 |
+
if "Xposition" in pos_channels:
|
| 205 |
+
local_pos[:, 0] = motion_data[:, pos_channels["Xposition"]]
|
| 206 |
+
if "Yposition" in pos_channels:
|
| 207 |
+
local_pos[:, 1] = motion_data[:, pos_channels["Yposition"]]
|
| 208 |
+
if "Zposition" in pos_channels:
|
| 209 |
+
local_pos[:, 2] = motion_data[:, pos_channels["Zposition"]]
|
| 210 |
+
else:
|
| 211 |
+
# Joints with only rotation channels: use static offset
|
| 212 |
+
local_pos = np.tile(offset, (n_frames, 1))
|
| 213 |
+
|
| 214 |
+
# Local rotation (all frames)
|
| 215 |
+
# BVH uses extrinsic rotations: uppercase in scipy convention
|
| 216 |
+
if rot_order:
|
| 217 |
+
rot_vals = motion_data[:, rot_ch_indices] # (n_frames, n_rot_channels)
|
| 218 |
+
local_rot = transform.Rotation.from_euler(
|
| 219 |
+
rot_order.upper(), rot_vals, degrees=True
|
| 220 |
+
).as_matrix()
|
| 221 |
+
else:
|
| 222 |
+
local_rot = np.tile(np.eye(3), (n_frames, 1, 1))
|
| 223 |
+
|
| 224 |
+
if joint["parent_idx"] < 0:
|
| 225 |
+
# Root joint: no parent transform
|
| 226 |
+
world_rots[:, j_idx] = local_rot
|
| 227 |
+
world_pos[:, j_idx] = local_pos
|
| 228 |
+
else:
|
| 229 |
+
p = joint["parent_idx"]
|
| 230 |
+
parent_rot = world_rots[:, p] # (n_frames, 3, 3)
|
| 231 |
+
parent_pos = world_pos[:, p] # (n_frames, 3)
|
| 232 |
+
# world_pos = parent_pos + parent_rot @ local_pos
|
| 233 |
+
world_pos[:, j_idx] = parent_pos + np.einsum("fij,fj->fi", parent_rot, local_pos)
|
| 234 |
+
# world_rot = parent_rot @ local_rot
|
| 235 |
+
world_rots[:, j_idx] = np.einsum("fij,fjk->fik", parent_rot, local_rot)
|
| 236 |
+
|
| 237 |
+
# Extract selected joints
|
| 238 |
+
sel_indices = [joint_names.index(name) for name in selected_names if name in joint_names]
|
| 239 |
+
selected_positions = world_pos[:, sel_indices, :] # (n_frames, len(selected_names), 3)
|
| 240 |
+
|
| 241 |
+
# Extract root quaternion (Hips joint, index 1)
|
| 242 |
+
hips_idx = joint_names.index("Hips") if "Hips" in joint_names else 0
|
| 243 |
+
root_quats_scipy = transform.Rotation.from_matrix(world_rots[:, hips_idx])
|
| 244 |
+
root_quats = root_quats_scipy.as_quat() # (n_frames, 4) as xyzw
|
| 245 |
+
|
| 246 |
+
return selected_positions, root_quats
|
| 247 |
+
|
| 248 |
+
|
| 249 |
+
def process_single_bvh(args):
|
| 250 |
+
"""Process a single BVH file → PKL with soma_joints.
|
| 251 |
+
|
| 252 |
+
Returns (motion_name, success, error_msg)
|
| 253 |
+
"""
|
| 254 |
+
bvh_path, output_dir, fps_target, skip_existing = args
|
| 255 |
+
motion_name = osp.splitext(osp.basename(bvh_path))[0]
|
| 256 |
+
output_path = osp.join(output_dir, f"{motion_name}.pkl")
|
| 257 |
+
|
| 258 |
+
if skip_existing and osp.exists(output_path):
|
| 259 |
+
return motion_name, True, "skipped"
|
| 260 |
+
|
| 261 |
+
try:
|
| 262 |
+
joints, channel_order, motion_data, n_frames, frame_time = parse_bvh(bvh_path)
|
| 263 |
+
fps_source = round(1.0 / frame_time)
|
| 264 |
+
|
| 265 |
+
positions, root_quats = compute_fk_selected(joints, channel_order, motion_data, SOMA_JOINTS)
|
| 266 |
+
|
| 267 |
+
# Convert cm → meters
|
| 268 |
+
positions_m = positions / 100.0
|
| 269 |
+
|
| 270 |
+
# Extract hips translation and subtract from all joints to get
|
| 271 |
+
# body-local positions (matching SMPL's compute_human_joints which
|
| 272 |
+
# produces joints without global translation).
|
| 273 |
+
hips_idx = 0 # Hips is joint index 0 in SOMA_JOINTS (Root removed)
|
| 274 |
+
transl = positions_m[:, hips_idx, :].copy() # (T, 3) Y-up
|
| 275 |
+
positions_m = positions_m - transl[:, None, :] # body-local
|
| 276 |
+
|
| 277 |
+
# Convert Y-up → Z-up: (x, y, z) → (x, -z, y)
|
| 278 |
+
# Same as applying rot90x, matching SMPL's convert_smpl_bones which
|
| 279 |
+
# applies rot90x to global_orient before FK to produce Z-up joints.
|
| 280 |
+
positions_zup = positions_m.copy()
|
| 281 |
+
positions_zup[..., 1] = -positions_m[..., 2]
|
| 282 |
+
positions_zup[..., 2] = positions_m[..., 1]
|
| 283 |
+
|
| 284 |
+
# Downsample to target fps using stride-based frame skipping.
|
| 285 |
+
# Matches convert_soma_csv_to_motion_lib.py: jump = int(fps_source / fps_target).
|
| 286 |
+
# For Bones-SEED (120fps BVH → 30fps), this is stride-4 (exact division).
|
| 287 |
+
# Both BVH and CSV have identical source frame counts at 120fps, so
|
| 288 |
+
# stride-based downsampling produces identical frame counts.
|
| 289 |
+
if fps_source != fps_target:
|
| 290 |
+
jump = max(1, int(fps_source / fps_target))
|
| 291 |
+
positions_zup = positions_zup[::jump]
|
| 292 |
+
transl = transl[::jump]
|
| 293 |
+
root_quats = root_quats[::jump]
|
| 294 |
+
|
| 295 |
+
# Convert xyzw → wxyz for compatibility with IsaacLab quat pipeline.
|
| 296 |
+
# Root quats stay Y-up — runtime converts via smpl_root_ytoz_up +
|
| 297 |
+
# remove_bvh_base_rot (same pattern as SMPL pose_aa).
|
| 298 |
+
root_quats = root_quats[:, [3, 0, 1, 2]]
|
| 299 |
+
|
| 300 |
+
# Store as PKL
|
| 301 |
+
entry = {
|
| 302 |
+
motion_name: {
|
| 303 |
+
"soma_joints": positions_zup.astype(
|
| 304 |
+
np.float32
|
| 305 |
+
), # (T, 26, 3) Z-up meters, body-local
|
| 306 |
+
"soma_root_quat": root_quats.astype(
|
| 307 |
+
np.float32
|
| 308 |
+
), # (T, 4) wxyz, Y-up BVH world rotation
|
| 309 |
+
"soma_transl": transl.astype(np.float32), # (T, 3) Hips world position, Y-up
|
| 310 |
+
"fps": fps_target,
|
| 311 |
+
"joint_names": SOMA_JOINTS,
|
| 312 |
+
}
|
| 313 |
+
}
|
| 314 |
+
|
| 315 |
+
os.makedirs(output_dir, exist_ok=True)
|
| 316 |
+
joblib.dump(entry, output_path)
|
| 317 |
+
return motion_name, True, None
|
| 318 |
+
|
| 319 |
+
except Exception as e:
|
| 320 |
+
return motion_name, False, str(e)
|
| 321 |
+
|
| 322 |
+
|
| 323 |
+
def main():
|
| 324 |
+
parser = argparse.ArgumentParser(description="Extract SOMA joints from BVH files")
|
| 325 |
+
parser.add_argument("--input", required=True, help="BVH dir (session or parent)")
|
| 326 |
+
parser.add_argument("--output", required=True, help="Output dir for PKL files")
|
| 327 |
+
parser.add_argument(
|
| 328 |
+
"--fps",
|
| 329 |
+
type=int,
|
| 330 |
+
default=30,
|
| 331 |
+
help="Target FPS (default: 30, matches process_bones_to_motionlib)",
|
| 332 |
+
)
|
| 333 |
+
parser.add_argument("--num_workers", type=int, default=4, help="Parallel workers")
|
| 334 |
+
parser.add_argument("--skip_existing", action="store_true", help="Skip existing PKL files")
|
| 335 |
+
args = parser.parse_args()
|
| 336 |
+
|
| 337 |
+
# Discover BVH files
|
| 338 |
+
bvh_files = sorted(glob.glob(osp.join(args.input, "*.bvh")))
|
| 339 |
+
|
| 340 |
+
if bvh_files:
|
| 341 |
+
# Single session directory
|
| 342 |
+
sessions = {osp.basename(args.input): bvh_files}
|
| 343 |
+
else:
|
| 344 |
+
# Parent directory with session subdirs
|
| 345 |
+
session_dirs = sorted([d for d in glob.glob(osp.join(args.input, "*")) if osp.isdir(d)])
|
| 346 |
+
sessions = {}
|
| 347 |
+
for sd in session_dirs:
|
| 348 |
+
files = sorted(glob.glob(osp.join(sd, "*.bvh")))
|
| 349 |
+
if files:
|
| 350 |
+
sessions[osp.basename(sd)] = files
|
| 351 |
+
|
| 352 |
+
if not sessions:
|
| 353 |
+
print(f"No BVH files found in {args.input}")
|
| 354 |
+
sys.exit(1)
|
| 355 |
+
|
| 356 |
+
total_bvh = sum(len(v) for v in sessions.values())
|
| 357 |
+
print(f"Found {total_bvh} BVH files across {len(sessions)} sessions")
|
| 358 |
+
print(f"Output: {args.output}, FPS: {args.fps}, Workers: {args.num_workers}")
|
| 359 |
+
total_converted = 0
|
| 360 |
+
total_failed = 0
|
| 361 |
+
t0 = time.time()
|
| 362 |
+
|
| 363 |
+
for session_name, bvh_list in sessions.items():
|
| 364 |
+
session_output = osp.join(args.output, session_name)
|
| 365 |
+
tasks = [(bvh_path, session_output, args.fps, args.skip_existing) for bvh_path in bvh_list]
|
| 366 |
+
|
| 367 |
+
with multiprocessing.Pool(args.num_workers) as pool:
|
| 368 |
+
results = pool.map(process_single_bvh, tasks)
|
| 369 |
+
|
| 370 |
+
converted = sum(1 for _, s, e in results if s and e != "skipped")
|
| 371 |
+
skipped = sum(1 for _, s, e in results if e == "skipped")
|
| 372 |
+
failed = sum(1 for _, s, _ in results if not s)
|
| 373 |
+
|
| 374 |
+
if failed > 0:
|
| 375 |
+
for name, success, err in results:
|
| 376 |
+
if not success:
|
| 377 |
+
print(f" FAILED: {name}: {err}")
|
| 378 |
+
|
| 379 |
+
total_converted += converted + skipped
|
| 380 |
+
total_failed += failed
|
| 381 |
+
|
| 382 |
+
elapsed = time.time() - t0
|
| 383 |
+
rate = total_converted / elapsed if elapsed > 0 else 0
|
| 384 |
+
print(
|
| 385 |
+
f" {session_name}: {converted} converted, {skipped} skipped, "
|
| 386 |
+
f"{failed} failed [{total_converted}/{total_bvh}, {rate:.0f}/s]"
|
| 387 |
+
)
|
| 388 |
+
|
| 389 |
+
elapsed = time.time() - t0
|
| 390 |
+
print(f"\nDone: {total_converted} converted, {total_failed} failed, " f"{elapsed:.1f}s elapsed")
|
| 391 |
+
|
| 392 |
+
|
| 393 |
+
if __name__ == "__main__":
|
| 394 |
+
main()
|
GR00T-WholeBodyControl/gear_sonic/data_process/filter_and_copy_bones_data.py
ADDED
|
@@ -0,0 +1,294 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to filter and copy bones data from bones_gmr to single_pkls directory.
|
| 4 |
+
|
| 5 |
+
This script copies motion files from the bones_gmr directory structure while
|
| 6 |
+
filtering out unwanted sequences based on keywords in filenames.
|
| 7 |
+
It preserves the bones_xxx directory structure in the destination.
|
| 8 |
+
"""
|
| 9 |
+
|
| 10 |
+
import argparse
|
| 11 |
+
from functools import partial
|
| 12 |
+
import glob
|
| 13 |
+
from multiprocessing import Pool, cpu_count
|
| 14 |
+
import os.path as osp
|
| 15 |
+
from pathlib import Path
|
| 16 |
+
import shutil
|
| 17 |
+
|
| 18 |
+
from tqdm import tqdm
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def should_filter_out(filename, filter_keywords, include_keywords=None):
|
| 22 |
+
"""
|
| 23 |
+
Check if a filename contains any of the filter keywords.
|
| 24 |
+
|
| 25 |
+
Args:
|
| 26 |
+
filename (str): The filename to check
|
| 27 |
+
filter_keywords (list): List of keywords to filter out
|
| 28 |
+
|
| 29 |
+
Returns:
|
| 30 |
+
bool: True if file should be filtered out, False otherwise
|
| 31 |
+
"""
|
| 32 |
+
filename_lower = filename.lower()
|
| 33 |
+
if include_keywords is None:
|
| 34 |
+
return any(keyword.lower() in filename_lower for keyword in filter_keywords)
|
| 35 |
+
else:
|
| 36 |
+
return any(keyword.lower() in filename_lower for keyword in filter_keywords) or (
|
| 37 |
+
not any(keyword.lower() in filename_lower for keyword in include_keywords)
|
| 38 |
+
)
|
| 39 |
+
|
| 40 |
+
|
| 41 |
+
def process_bones_directory(
|
| 42 |
+
bones_dir, dest_path, filter_keywords, dry_run, verbose, include_keywords=None
|
| 43 |
+
):
|
| 44 |
+
"""
|
| 45 |
+
Process a single bones directory - worker function for multiprocessing.
|
| 46 |
+
|
| 47 |
+
Args:
|
| 48 |
+
bones_dir (Path): Source bones directory to process
|
| 49 |
+
dest_path (Path): Destination base directory
|
| 50 |
+
filter_keywords (list): Keywords to filter out
|
| 51 |
+
dry_run (bool): If True, don't actually copy files
|
| 52 |
+
verbose (bool): If True, show detailed output
|
| 53 |
+
|
| 54 |
+
Returns:
|
| 55 |
+
tuple: (total_files, copied_files, filtered_files)
|
| 56 |
+
"""
|
| 57 |
+
dest_bones_dir = dest_path / bones_dir.name
|
| 58 |
+
|
| 59 |
+
if not dry_run:
|
| 60 |
+
dest_bones_dir.mkdir(parents=True, exist_ok=True)
|
| 61 |
+
|
| 62 |
+
# Find all pkl files in this bones directory
|
| 63 |
+
|
| 64 |
+
pkl_files = list(glob.glob(osp.join(bones_dir, "**", "*.pkl"), recursive=True))
|
| 65 |
+
total_files = len(pkl_files)
|
| 66 |
+
copied_files = 0
|
| 67 |
+
filtered_files = 0
|
| 68 |
+
|
| 69 |
+
if verbose:
|
| 70 |
+
print(f"Processing {bones_dir.name}: {total_files} files")
|
| 71 |
+
|
| 72 |
+
for pkl_file in pkl_files:
|
| 73 |
+
base = osp.basename(pkl_file)
|
| 74 |
+
parent = osp.basename(osp.dirname(pkl_file))
|
| 75 |
+
name_to_check = f"{parent}/{base}"
|
| 76 |
+
|
| 77 |
+
if (
|
| 78 |
+
should_filter_out(name_to_check, filter_keywords, include_keywords)
|
| 79 |
+
and not base == "metadata.pkl"
|
| 80 |
+
):
|
| 81 |
+
filtered_files += 1
|
| 82 |
+
if verbose:
|
| 83 |
+
print(f" FILTERED: {osp.basename(pkl_file)}")
|
| 84 |
+
else:
|
| 85 |
+
copied_files += 1
|
| 86 |
+
dest_file = osp.join(dest_bones_dir, osp.basename(pkl_file))
|
| 87 |
+
|
| 88 |
+
if not dry_run:
|
| 89 |
+
shutil.copy2(pkl_file, dest_file)
|
| 90 |
+
|
| 91 |
+
return (total_files, copied_files, filtered_files)
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
def copy_filtered_bones_data(
|
| 95 |
+
source_dir,
|
| 96 |
+
dest_dir,
|
| 97 |
+
filter_keywords,
|
| 98 |
+
dry_run=False,
|
| 99 |
+
verbose=False,
|
| 100 |
+
workers=None,
|
| 101 |
+
filter_file=None,
|
| 102 |
+
):
|
| 103 |
+
"""
|
| 104 |
+
Copy bones data while filtering out unwanted sequences.
|
| 105 |
+
|
| 106 |
+
Args:
|
| 107 |
+
source_dir (Path): Source directory containing bones_xxx subdirs
|
| 108 |
+
dest_dir (Path): Destination directory
|
| 109 |
+
filter_keywords (list): Keywords to filter out
|
| 110 |
+
dry_run (bool): If True, only show what would be copied
|
| 111 |
+
verbose (bool): If True, show detailed output
|
| 112 |
+
workers (int): Number of worker processes. If None, uses all CPU cores.
|
| 113 |
+
"""
|
| 114 |
+
source_path = Path(source_dir)
|
| 115 |
+
dest_path = Path(dest_dir)
|
| 116 |
+
include_keywords = None
|
| 117 |
+
if filter_file is not None:
|
| 118 |
+
with open(filter_file) as f:
|
| 119 |
+
include_keywords = f.read().splitlines()
|
| 120 |
+
|
| 121 |
+
if not source_path.exists():
|
| 122 |
+
print(f"Error: Source directory {source_path} does not exist!")
|
| 123 |
+
return False
|
| 124 |
+
|
| 125 |
+
# Find all bones_xxx directories
|
| 126 |
+
bones_dirs = [d for d in source_path.iterdir() if d.is_dir()]
|
| 127 |
+
|
| 128 |
+
if not bones_dirs:
|
| 129 |
+
print(f"No bones_xxx directories found in {source_path}")
|
| 130 |
+
return False
|
| 131 |
+
|
| 132 |
+
# Determine number of workers
|
| 133 |
+
if workers is None:
|
| 134 |
+
workers = cpu_count()
|
| 135 |
+
workers = max(1, min(workers, len(bones_dirs))) # Don't use more workers than directories
|
| 136 |
+
|
| 137 |
+
print(f"Found {len(bones_dirs)} bones directories to process")
|
| 138 |
+
print(f"Using {workers} worker processes")
|
| 139 |
+
|
| 140 |
+
if verbose:
|
| 141 |
+
for d in bones_dirs:
|
| 142 |
+
print(f" {d.name}")
|
| 143 |
+
|
| 144 |
+
# Process directories in parallel
|
| 145 |
+
worker_func = partial(
|
| 146 |
+
process_bones_directory,
|
| 147 |
+
dest_path=dest_path,
|
| 148 |
+
filter_keywords=filter_keywords,
|
| 149 |
+
dry_run=dry_run,
|
| 150 |
+
verbose=verbose,
|
| 151 |
+
include_keywords=include_keywords,
|
| 152 |
+
)
|
| 153 |
+
|
| 154 |
+
total_files = 0
|
| 155 |
+
copied_files = 0
|
| 156 |
+
filtered_files = 0
|
| 157 |
+
if workers == 1:
|
| 158 |
+
# Single-threaded execution for easier debugging
|
| 159 |
+
results = []
|
| 160 |
+
for bones_dir in tqdm(bones_dirs, desc="Processing bones directories"):
|
| 161 |
+
results.append(worker_func(bones_dir))
|
| 162 |
+
else:
|
| 163 |
+
# Multi-process execution
|
| 164 |
+
with Pool(processes=workers) as pool:
|
| 165 |
+
results = list(
|
| 166 |
+
tqdm(
|
| 167 |
+
pool.imap(worker_func, bones_dirs),
|
| 168 |
+
total=len(bones_dirs),
|
| 169 |
+
desc="Processing bones directories",
|
| 170 |
+
)
|
| 171 |
+
)
|
| 172 |
+
|
| 173 |
+
# Aggregate results
|
| 174 |
+
for result in results:
|
| 175 |
+
total_files += result[0]
|
| 176 |
+
copied_files += result[1]
|
| 177 |
+
filtered_files += result[2]
|
| 178 |
+
|
| 179 |
+
# Summary
|
| 180 |
+
print(f"\n{'='*60}")
|
| 181 |
+
print("FILTERING SUMMARY")
|
| 182 |
+
print(f"{'='*60}")
|
| 183 |
+
print(f"Source directory: {source_path}")
|
| 184 |
+
print(f"Destination directory: {dest_path}")
|
| 185 |
+
print(f"Total files found: {total_files}")
|
| 186 |
+
print(f"Files copied: {copied_files}")
|
| 187 |
+
print(f"Files filtered out: {filtered_files}")
|
| 188 |
+
print(f"Filter keywords: {', '.join(filter_keywords)}")
|
| 189 |
+
|
| 190 |
+
if dry_run:
|
| 191 |
+
print("\nDRY RUN - No files were actually copied")
|
| 192 |
+
else:
|
| 193 |
+
print(f"\nFiles successfully copied to: {dest_path}")
|
| 194 |
+
|
| 195 |
+
return True
|
| 196 |
+
|
| 197 |
+
|
| 198 |
+
def main():
|
| 199 |
+
parser = argparse.ArgumentParser(description="Filter and copy bones data")
|
| 200 |
+
parser.add_argument(
|
| 201 |
+
"--source",
|
| 202 |
+
default="data/bones_gmr/0903_all/",
|
| 203 |
+
help="Source directory containing bones_xxx subdirectories",
|
| 204 |
+
)
|
| 205 |
+
parser.add_argument(
|
| 206 |
+
"--dest", default="data/single_pkls/", help="Destination directory for filtered bones data"
|
| 207 |
+
)
|
| 208 |
+
parser.add_argument(
|
| 209 |
+
"--filter-keywords",
|
| 210 |
+
default=[
|
| 211 |
+
"bed",
|
| 212 |
+
"bike",
|
| 213 |
+
"chair",
|
| 214 |
+
"climb",
|
| 215 |
+
"com_up_50cm",
|
| 216 |
+
"sitting",
|
| 217 |
+
"step_on",
|
| 218 |
+
"seat",
|
| 219 |
+
"table",
|
| 220 |
+
"_sit_",
|
| 221 |
+
"sit_", "ladder",
|
| 222 |
+
"crutch",
|
| 223 |
+
"_bed_",
|
| 224 |
+
"_ride_",
|
| 225 |
+
"scooter",
|
| 226 |
+
"stepdown",
|
| 227 |
+
"acrobatics_",
|
| 228 |
+
"box_HSPU",
|
| 229 |
+
"cartwheel",
|
| 230 |
+
"50cm_box_",
|
| 231 |
+
"on_box", "fall_from",
|
| 232 |
+
"handstand_ff_",
|
| 233 |
+
"on_1m",
|
| 234 |
+
"form_box",
|
| 235 |
+
"off_1m",
|
| 236 |
+
"230m",
|
| 237 |
+
"jump_over_obstacle_",
|
| 238 |
+
"lift_crate_come_up_",
|
| 239 |
+
"jump_to_shoulder_roll",
|
| 240 |
+
"kozak_dance",
|
| 241 |
+
"stair",
|
| 242 |
+
"handstand",
|
| 243 |
+
"box_jump",
|
| 244 |
+
"monkey_jump",
|
| 245 |
+
"safety_roll",
|
| 246 |
+
"box_dips",
|
| 247 |
+
"walking_on_edge",
|
| 248 |
+
"push_obstacle",
|
| 249 |
+
],
|
| 250 |
+
nargs="+",
|
| 251 |
+
help="Keywords to filter out from filenames",
|
| 252 |
+
)
|
| 253 |
+
parser.add_argument(
|
| 254 |
+
"--dry-run", action="store_true", help="Show what would be copied without actually copying"
|
| 255 |
+
)
|
| 256 |
+
parser.add_argument("--verbose", "-v", action="store_true", help="Show detailed output")
|
| 257 |
+
parser.add_argument(
|
| 258 |
+
"--add-keywords", nargs="+", help="Additional keywords to add to the default filter list"
|
| 259 |
+
)
|
| 260 |
+
parser.add_argument(
|
| 261 |
+
"--workers",
|
| 262 |
+
type=int,
|
| 263 |
+
default=None,
|
| 264 |
+
help="Number of worker processes (default: use all CPU cores)",
|
| 265 |
+
)
|
| 266 |
+
|
| 267 |
+
parser.add_argument("--filter_file", default=None, help="Filter file to use")
|
| 268 |
+
|
| 269 |
+
args = parser.parse_args()
|
| 270 |
+
|
| 271 |
+
# Combine default and additional keywords
|
| 272 |
+
filter_keywords = args.filter_keywords
|
| 273 |
+
if args.add_keywords:
|
| 274 |
+
filter_keywords.extend(args.add_keywords)
|
| 275 |
+
|
| 276 |
+
print(f"Filtering out files containing: {', '.join(filter_keywords)}")
|
| 277 |
+
|
| 278 |
+
success = copy_filtered_bones_data(
|
| 279 |
+
source_dir=args.source,
|
| 280 |
+
dest_dir=args.dest,
|
| 281 |
+
filter_keywords=filter_keywords,
|
| 282 |
+
dry_run=args.dry_run,
|
| 283 |
+
verbose=args.verbose,
|
| 284 |
+
workers=args.workers,
|
| 285 |
+
filter_file=args.filter_file,
|
| 286 |
+
)
|
| 287 |
+
|
| 288 |
+
return 0 if success else 1
|
| 289 |
+
|
| 290 |
+
|
| 291 |
+
if __name__ == "__main__":
|
| 292 |
+
import sys
|
| 293 |
+
|
| 294 |
+
sys.exit(main())
|
GR00T-WholeBodyControl/gear_sonic/data_process/split_pkl_files.py
ADDED
|
@@ -0,0 +1,193 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
#!/usr/bin/env python3
|
| 2 |
+
"""
|
| 3 |
+
Script to break large pickle files into individual motion sequence files.
|
| 4 |
+
|
| 5 |
+
This script reads large pickle files and breaks each motion sequence
|
| 6 |
+
within them into individual pickle files in subdirectories.
|
| 7 |
+
This enables motion_lib_base.py to use directory mode for efficient loading.
|
| 8 |
+
Supports any pkl file format, not just bone motion files.
|
| 9 |
+
"""
|
| 10 |
+
|
| 11 |
+
import argparse
|
| 12 |
+
from pathlib import Path
|
| 13 |
+
import shutil
|
| 14 |
+
import sys
|
| 15 |
+
import time
|
| 16 |
+
|
| 17 |
+
import joblib
|
| 18 |
+
from tqdm import tqdm
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
def create_output_structure(output_dir, clean=False):
|
| 22 |
+
"""Create the output directory structure."""
|
| 23 |
+
output_path = Path(output_dir)
|
| 24 |
+
|
| 25 |
+
if clean and output_path.exists():
|
| 26 |
+
print(f"Removing existing output directory: {output_path}")
|
| 27 |
+
shutil.rmtree(output_path)
|
| 28 |
+
|
| 29 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 30 |
+
print(f"Created output directory: {output_path}")
|
| 31 |
+
return output_path
|
| 32 |
+
|
| 33 |
+
|
| 34 |
+
def extract_motion_metadata(motion_data):
|
| 35 |
+
"""Extract metadata (length, fps) from motion data."""
|
| 36 |
+
metadata = {}
|
| 37 |
+
|
| 38 |
+
# Check common fields that might indicate fps
|
| 39 |
+
fps = None
|
| 40 |
+
if hasattr(motion_data, "get"):
|
| 41 |
+
fps = motion_data.get("fps", motion_data.get("frame_rate", motion_data.get("framerate")))
|
| 42 |
+
|
| 43 |
+
# If no fps found, try to infer from common values or set default
|
| 44 |
+
if fps is None:
|
| 45 |
+
fps = 30.0 # Default fps
|
| 46 |
+
|
| 47 |
+
# Get length - check for common motion data structures
|
| 48 |
+
length = 0
|
| 49 |
+
length = motion_data["root_trans_offset"].shape[0]
|
| 50 |
+
|
| 51 |
+
# If still no length found, try to get it from the data structure
|
| 52 |
+
if length == 0 and hasattr(motion_data, "__len__"):
|
| 53 |
+
length = len(motion_data)
|
| 54 |
+
|
| 55 |
+
return {"length": length, "fps": fps, "duration": length / fps if fps > 0 else 0.0}
|
| 56 |
+
|
| 57 |
+
|
| 58 |
+
def process_motion_file(input_file, output_dir, verbose=False):
|
| 59 |
+
"""Process a single large motion file and break it into individual files."""
|
| 60 |
+
input_path = Path(input_file)
|
| 61 |
+
|
| 62 |
+
# Create subdirectory named after the input file
|
| 63 |
+
file_subdir = input_path.stem
|
| 64 |
+
output_path = Path(output_dir) / file_subdir
|
| 65 |
+
output_path.mkdir(parents=True, exist_ok=True)
|
| 66 |
+
|
| 67 |
+
try:
|
| 68 |
+
print(f"Loading {input_path.name}...")
|
| 69 |
+
motion_data = joblib.load(input_path)
|
| 70 |
+
print(f"Loaded {len(motion_data)} motion sequences -> {output_path}")
|
| 71 |
+
|
| 72 |
+
# Collect metadata for all motion sequences
|
| 73 |
+
metadata = {}
|
| 74 |
+
|
| 75 |
+
# Process each motion sequence
|
| 76 |
+
for motion_key, motion_sequence_data in tqdm(
|
| 77 |
+
motion_data.items(), desc=f"Processing {input_path.name}"
|
| 78 |
+
):
|
| 79 |
+
individual_filepath = output_path / f"{motion_key}.pkl"
|
| 80 |
+
individual_dict = {motion_key: motion_sequence_data}
|
| 81 |
+
joblib.dump(individual_dict, individual_filepath)
|
| 82 |
+
|
| 83 |
+
# Extract metadata for this motion sequence
|
| 84 |
+
motion_metadata = extract_motion_metadata(motion_sequence_data)
|
| 85 |
+
metadata[motion_key] = motion_metadata
|
| 86 |
+
|
| 87 |
+
# Save metadata file
|
| 88 |
+
metadata_filepath = output_path / "metadata.pkl"
|
| 89 |
+
joblib.dump(metadata, metadata_filepath)
|
| 90 |
+
|
| 91 |
+
if verbose:
|
| 92 |
+
print(
|
| 93 |
+
f"Successfully processed {input_path.name} -> {len(motion_data)} individual files + metadata"
|
| 94 |
+
)
|
| 95 |
+
return True
|
| 96 |
+
|
| 97 |
+
except Exception as e:
|
| 98 |
+
print(f"Error processing {input_path.name}: {e}")
|
| 99 |
+
return False
|
| 100 |
+
|
| 101 |
+
|
| 102 |
+
def main():
|
| 103 |
+
parser = argparse.ArgumentParser(description="Break pickle files into individual sequences")
|
| 104 |
+
parser.add_argument("input", help="Input directory containing pkl files or single pkl file")
|
| 105 |
+
parser.add_argument(
|
| 106 |
+
"--output",
|
| 107 |
+
default="data/processed_pkl/",
|
| 108 |
+
help="Output directory for individual motion files",
|
| 109 |
+
)
|
| 110 |
+
parser.add_argument("--verbose", "-v", action="store_true", help="Enable verbose output")
|
| 111 |
+
parser.add_argument(
|
| 112 |
+
"--file-pattern",
|
| 113 |
+
default="*.pkl",
|
| 114 |
+
help="Pattern to match input files (only used for directories)",
|
| 115 |
+
)
|
| 116 |
+
parser.add_argument(
|
| 117 |
+
"--clean", action="store_true", help="Remove output directory if it already exists"
|
| 118 |
+
)
|
| 119 |
+
|
| 120 |
+
args = parser.parse_args()
|
| 121 |
+
|
| 122 |
+
input_path = Path(args.input)
|
| 123 |
+
if not input_path.exists():
|
| 124 |
+
print(f"Error: Input path {input_path} does not exist!")
|
| 125 |
+
return 1
|
| 126 |
+
|
| 127 |
+
# Create output directory
|
| 128 |
+
output_path = create_output_structure(args.output, args.clean)
|
| 129 |
+
|
| 130 |
+
# Determine input files
|
| 131 |
+
if input_path.is_file():
|
| 132 |
+
# Single file input
|
| 133 |
+
if not input_path.suffix == ".pkl":
|
| 134 |
+
print(f"Error: Input file must be a .pkl file, got {input_path.suffix}")
|
| 135 |
+
return 1
|
| 136 |
+
input_files = [input_path]
|
| 137 |
+
print(f"Processing single file: {input_path}")
|
| 138 |
+
else:
|
| 139 |
+
# Directory input
|
| 140 |
+
input_files = sorted(input_path.glob(args.file_pattern))
|
| 141 |
+
if not input_files:
|
| 142 |
+
print(f"No files found matching pattern {args.file_pattern} in {input_path}")
|
| 143 |
+
return 1
|
| 144 |
+
print(f"Processing directory: {input_path}")
|
| 145 |
+
|
| 146 |
+
print(f"Found {len(input_files)} files to process:")
|
| 147 |
+
for f in input_files[:5]:
|
| 148 |
+
print(f" {f.name}")
|
| 149 |
+
if len(input_files) > 5:
|
| 150 |
+
print(f" ... and {len(input_files) - 5} more files")
|
| 151 |
+
|
| 152 |
+
# Process all files
|
| 153 |
+
successful = failed = total_individual_files = 0
|
| 154 |
+
start_time = time.time()
|
| 155 |
+
|
| 156 |
+
for input_file in input_files:
|
| 157 |
+
print(f"\n{'='*60}")
|
| 158 |
+
if process_motion_file(input_file, output_path, args.verbose):
|
| 159 |
+
successful += 1
|
| 160 |
+
# Count files created (excluding metadata)
|
| 161 |
+
subdir_path = output_path / input_file.stem
|
| 162 |
+
individual_files = [
|
| 163 |
+
f for f in subdir_path.glob("*.pkl") if not f.name.endswith("metadata.pkl")
|
| 164 |
+
]
|
| 165 |
+
total_individual_files += len(individual_files)
|
| 166 |
+
print(
|
| 167 |
+
f"Created {len(individual_files)} individual files + metadata from {input_file.name}"
|
| 168 |
+
)
|
| 169 |
+
else:
|
| 170 |
+
failed += 1
|
| 171 |
+
|
| 172 |
+
elapsed = time.time() - start_time
|
| 173 |
+
|
| 174 |
+
# Summary
|
| 175 |
+
print(f"\n{'='*60}")
|
| 176 |
+
print("PROCESSING SUMMARY")
|
| 177 |
+
print(f"{'='*60}")
|
| 178 |
+
print(f"Input: {input_path}")
|
| 179 |
+
print(f"Output directory: {output_path}")
|
| 180 |
+
print(f"Files processed successfully: {successful}")
|
| 181 |
+
print(f"Files failed: {failed}")
|
| 182 |
+
print(f"Total individual motion files created: {total_individual_files}")
|
| 183 |
+
print(f"Processing time: {elapsed:.2f} seconds")
|
| 184 |
+
|
| 185 |
+
if successful > 0:
|
| 186 |
+
print(f"\nSuccess! Individual motion files are available in: {output_path}")
|
| 187 |
+
print(f"To use with motion_lib_base.py: motion_file = '{output_path}'")
|
| 188 |
+
|
| 189 |
+
return 0 if failed == 0 else 1
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
if __name__ == "__main__":
|
| 193 |
+
sys.exit(main())
|
GR00T-WholeBodyControl/gear_sonic/envs/__init__.py
ADDED
|
File without changes
|
GR00T-WholeBodyControl/gear_sonic/isaac_utils/__init__.py
ADDED
|
File without changes
|
GR00T-WholeBodyControl/gear_sonic/isaac_utils/maths.py
ADDED
|
@@ -0,0 +1,52 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Basic math primitives for Isaac Lab: normalization, random floats, copysign, seeding."""
|
| 2 |
+
|
| 3 |
+
import torch
|
| 4 |
+
import numpy as np
|
| 5 |
+
import random
|
| 6 |
+
import os
|
| 7 |
+
|
| 8 |
+
|
| 9 |
+
@torch.jit.script
|
| 10 |
+
def normalize(x, eps: float = 1e-9):
|
| 11 |
+
return x / x.norm(p=2, dim=-1).clamp(min=eps, max=None).unsqueeze(-1)
|
| 12 |
+
|
| 13 |
+
|
| 14 |
+
@torch.jit.script
|
| 15 |
+
def torch_rand_float(lower, upper, shape, device):
|
| 16 |
+
# type: (float, float, Tuple[int, int], str) -> Tensor
|
| 17 |
+
return (upper - lower) * torch.rand(*shape, device=device) + lower
|
| 18 |
+
|
| 19 |
+
|
| 20 |
+
@torch.jit.script
|
| 21 |
+
def copysign(a, b):
|
| 22 |
+
# type: (float, Tensor) -> Tensor
|
| 23 |
+
a = torch.tensor(a, device=b.device, dtype=torch.float).repeat(b.shape[0])
|
| 24 |
+
return torch.abs(a) * torch.sign(b)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
def set_seed(seed, torch_deterministic=False):
|
| 28 |
+
"""set seed across modules"""
|
| 29 |
+
if seed == -1 and torch_deterministic:
|
| 30 |
+
seed = 42
|
| 31 |
+
elif seed == -1:
|
| 32 |
+
seed = np.random.randint(0, 10000)
|
| 33 |
+
print("Setting seed: {}".format(seed))
|
| 34 |
+
|
| 35 |
+
random.seed(seed)
|
| 36 |
+
np.random.seed(seed)
|
| 37 |
+
torch.manual_seed(seed)
|
| 38 |
+
os.environ["PYTHONHASHSEED"] = str(seed)
|
| 39 |
+
torch.cuda.manual_seed(seed)
|
| 40 |
+
torch.cuda.manual_seed_all(seed)
|
| 41 |
+
|
| 42 |
+
if torch_deterministic:
|
| 43 |
+
# refer to https://docs.nvidia.com/cuda/cublas/index.html#cublasApi_reproducibility
|
| 44 |
+
os.environ["CUBLAS_WORKSPACE_CONFIG"] = ":4096:8"
|
| 45 |
+
torch.backends.cudnn.benchmark = False
|
| 46 |
+
torch.backends.cudnn.deterministic = True
|
| 47 |
+
torch.use_deterministic_algorithms(True)
|
| 48 |
+
else:
|
| 49 |
+
torch.backends.cudnn.benchmark = True
|
| 50 |
+
torch.backends.cudnn.deterministic = False
|
| 51 |
+
|
| 52 |
+
return seed
|
GR00T-WholeBodyControl/gear_sonic/isaac_utils/rotations.py
ADDED
|
@@ -0,0 +1,787 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""JIT-compiled quaternion and rotation utilities for Isaac environments.
|
| 2 |
+
|
| 3 |
+
Provides quaternion arithmetic (multiply, inverse, conjugate, slerp), conversions
|
| 4 |
+
(axis-angle, rotation matrix, euler), and specialized helpers for SMPL root
|
| 5 |
+
orientation transforms (Y-up to Z-up, base rotation removal).
|
| 6 |
+
"""
|
| 7 |
+
|
| 8 |
+
import torch
|
| 9 |
+
from torch import Tensor
|
| 10 |
+
import torch.nn.functional as F
|
| 11 |
+
from gear_sonic.isaac_utils.maths import (
|
| 12 |
+
normalize,
|
| 13 |
+
copysign,
|
| 14 |
+
)
|
| 15 |
+
from gear_sonic.trl.utils.torch_transform import angle_axis_to_quaternion, quaternion_to_angle_axis
|
| 16 |
+
from typing import Tuple
|
| 17 |
+
import numpy as np
|
| 18 |
+
from typing import List, Optional
|
| 19 |
+
|
| 20 |
+
|
| 21 |
+
@torch.jit.script
|
| 22 |
+
def quat_unit(a):
|
| 23 |
+
"""Normalize quaternion to unit length."""
|
| 24 |
+
return normalize(a)
|
| 25 |
+
|
| 26 |
+
|
| 27 |
+
@torch.jit.script
|
| 28 |
+
def quat_apply(a: Tensor, b: Tensor, w_last: bool) -> Tensor:
|
| 29 |
+
shape = b.shape
|
| 30 |
+
a = a.reshape(-1, 4)
|
| 31 |
+
b = b.reshape(-1, 3)
|
| 32 |
+
if w_last:
|
| 33 |
+
xyz = a[:, :3]
|
| 34 |
+
w = a[:, 3:]
|
| 35 |
+
else:
|
| 36 |
+
xyz = a[:, 1:]
|
| 37 |
+
w = a[:, :1]
|
| 38 |
+
t = xyz.cross(b, dim=-1) * 2
|
| 39 |
+
return (b + w * t + xyz.cross(t, dim=-1)).view(shape)
|
| 40 |
+
|
| 41 |
+
|
| 42 |
+
def get_yaw_quat_from_quat(quat_angle):
|
| 43 |
+
rpy = get_euler_xyz_in_tensor(quat_angle)
|
| 44 |
+
roll, pitch, yaw = rpy[:, 0], rpy[:, 1], rpy[:, 2]
|
| 45 |
+
roll = torch.zeros_like(roll)
|
| 46 |
+
pitch = torch.zeros_like(pitch)
|
| 47 |
+
return quat_from_euler_xyz(roll, pitch, yaw)
|
| 48 |
+
|
| 49 |
+
|
| 50 |
+
@torch.jit.script
|
| 51 |
+
def yaw_quat(quat: torch.Tensor) -> torch.Tensor:
|
| 52 |
+
"""Extract the yaw component of a quaternion.
|
| 53 |
+
|
| 54 |
+
Args:
|
| 55 |
+
quat: The orientation in (w, x, y, z). Shape is (..., 4)
|
| 56 |
+
|
| 57 |
+
Returns:
|
| 58 |
+
A quaternion with only yaw component.
|
| 59 |
+
"""
|
| 60 |
+
shape = quat.shape
|
| 61 |
+
quat_yaw = quat.view(-1, 4)
|
| 62 |
+
qw = quat_yaw[:, 0]
|
| 63 |
+
qx = quat_yaw[:, 1]
|
| 64 |
+
qy = quat_yaw[:, 2]
|
| 65 |
+
qz = quat_yaw[:, 3]
|
| 66 |
+
yaw = torch.atan2(2 * (qw * qz + qx * qy), 1 - 2 * (qy * qy + qz * qz))
|
| 67 |
+
quat_yaw = torch.zeros_like(quat_yaw)
|
| 68 |
+
quat_yaw[:, 3] = torch.sin(yaw / 2)
|
| 69 |
+
quat_yaw[:, 0] = torch.cos(yaw / 2)
|
| 70 |
+
quat_yaw = normalize(quat_yaw)
|
| 71 |
+
return quat_yaw.view(shape)
|
| 72 |
+
|
| 73 |
+
|
| 74 |
+
@torch.jit.script
|
| 75 |
+
def wrap_to_pi(angles):
|
| 76 |
+
angles %= 2 * np.pi
|
| 77 |
+
angles -= 2 * np.pi * (angles > np.pi)
|
| 78 |
+
return angles
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
@torch.jit.script
|
| 82 |
+
def quat_conjugate(a: Tensor, w_last: bool) -> Tensor:
|
| 83 |
+
shape = a.shape
|
| 84 |
+
a = a.reshape(-1, 4)
|
| 85 |
+
if w_last:
|
| 86 |
+
return torch.cat((-a[:, :3], a[:, -1:]), dim=-1).view(shape)
|
| 87 |
+
else:
|
| 88 |
+
return torch.cat((a[:, 0:1], -a[:, 1:]), dim=-1).view(shape)
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
@torch.jit.script
|
| 94 |
+
def quat_rotate(q: Tensor, v: Tensor, w_last: bool) -> Tensor:
|
| 95 |
+
shape = q.shape
|
| 96 |
+
if w_last:
|
| 97 |
+
q_w = q[:, -1]
|
| 98 |
+
q_vec = q[:, :3]
|
| 99 |
+
else:
|
| 100 |
+
q_w = q[:, 0]
|
| 101 |
+
q_vec = q[:, 1:]
|
| 102 |
+
a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1)
|
| 103 |
+
b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0
|
| 104 |
+
c = q_vec * torch.bmm(q_vec.view(shape[0], 1, 3), v.view(shape[0], 3, 1)).squeeze(-1) * 2.0
|
| 105 |
+
return a + b + c
|
| 106 |
+
|
| 107 |
+
|
| 108 |
+
@torch.jit.script
|
| 109 |
+
def quat_rotate_inverse(q: Tensor, v: Tensor, w_last: bool) -> Tensor:
|
| 110 |
+
# Same as quat_rotate but with the cross-product term (b) negated,
|
| 111 |
+
# which is equivalent to rotating by the conjugate quaternion (inverse rotation).
|
| 112 |
+
shape = q.shape
|
| 113 |
+
if w_last:
|
| 114 |
+
q_w = q[:, -1]
|
| 115 |
+
q_vec = q[:, :3]
|
| 116 |
+
else:
|
| 117 |
+
q_w = q[:, 0]
|
| 118 |
+
q_vec = q[:, 1:]
|
| 119 |
+
a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1)
|
| 120 |
+
b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0
|
| 121 |
+
c = q_vec * torch.bmm(q_vec.view(shape[0], 1, 3), v.view(shape[0], 3, 1)).squeeze(-1) * 2.0
|
| 122 |
+
return a - b + c
|
| 123 |
+
|
| 124 |
+
|
| 125 |
+
@torch.jit.script
|
| 126 |
+
def quat_angle_axis(x: Tensor, w_last: bool) -> Tuple[Tensor, Tensor]:
|
| 127 |
+
"""
|
| 128 |
+
The (angle, axis) representation of the rotation. The axis is normalized to unit length.
|
| 129 |
+
The angle is guaranteed to be between [0, pi].
|
| 130 |
+
"""
|
| 131 |
+
if w_last:
|
| 132 |
+
w = x[..., -1]
|
| 133 |
+
axis = x[..., :3]
|
| 134 |
+
else:
|
| 135 |
+
w = x[..., 0]
|
| 136 |
+
axis = x[..., 1:]
|
| 137 |
+
# cos(theta) = 2*w^2 - 1, derived from w = cos(theta/2) and double-angle formula
|
| 138 |
+
s = 2 * (w**2) - 1
|
| 139 |
+
angle = s.clamp(-1, 1).arccos() # just to be safe
|
| 140 |
+
axis /= axis.norm(p=2, dim=-1, keepdim=True).clamp(min=1e-9)
|
| 141 |
+
return angle, axis
|
| 142 |
+
|
| 143 |
+
|
| 144 |
+
@torch.jit.script
|
| 145 |
+
def quat_from_angle_axis(angle: Tensor, axis: Tensor, w_last: bool) -> Tensor:
|
| 146 |
+
theta = (angle / 2).unsqueeze(-1)
|
| 147 |
+
xyz = normalize(axis) * theta.sin()
|
| 148 |
+
w = theta.cos()
|
| 149 |
+
if w_last:
|
| 150 |
+
return quat_unit(torch.cat([xyz, w], dim=-1))
|
| 151 |
+
else:
|
| 152 |
+
return quat_unit(torch.cat([w, xyz], dim=-1))
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
@torch.jit.script
|
| 156 |
+
def vec_to_heading(h_vec):
|
| 157 |
+
h_theta = torch.atan2(h_vec[..., 1], h_vec[..., 0])
|
| 158 |
+
return h_theta
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
@torch.jit.script
|
| 162 |
+
def heading_to_quat(h_theta, w_last: bool):
|
| 163 |
+
axis = torch.zeros(
|
| 164 |
+
h_theta.shape
|
| 165 |
+
+ [
|
| 166 |
+
3,
|
| 167 |
+
],
|
| 168 |
+
device=h_theta.device,
|
| 169 |
+
)
|
| 170 |
+
axis[..., 2] = 1
|
| 171 |
+
heading_q = quat_from_angle_axis(h_theta, axis, w_last=w_last)
|
| 172 |
+
return heading_q
|
| 173 |
+
|
| 174 |
+
|
| 175 |
+
@torch.jit.script
|
| 176 |
+
def quat_axis(q: Tensor, axis: int, w_last: bool) -> Tensor:
|
| 177 |
+
basis_vec = torch.zeros(q.shape[0], 3, device=q.device)
|
| 178 |
+
basis_vec[:, axis] = 1
|
| 179 |
+
return quat_rotate(q, basis_vec, w_last)
|
| 180 |
+
|
| 181 |
+
|
| 182 |
+
@torch.jit.script
|
| 183 |
+
def normalize_angle(x):
|
| 184 |
+
return torch.atan2(torch.sin(x), torch.cos(x))
|
| 185 |
+
|
| 186 |
+
|
| 187 |
+
@torch.jit.script
|
| 188 |
+
def get_basis_vector(q: Tensor, v: Tensor, w_last: bool) -> Tensor:
|
| 189 |
+
return quat_rotate(q, v, w_last)
|
| 190 |
+
|
| 191 |
+
|
| 192 |
+
@torch.jit.script
|
| 193 |
+
def quat_to_angle_axis(q, w_last: bool):
|
| 194 |
+
# type: (Tensor, bool) -> Tuple[Tensor, Tensor]
|
| 195 |
+
# computes axis-angle representation from quaternion q
|
| 196 |
+
# q must be normalized
|
| 197 |
+
# ZL: could have issues.
|
| 198 |
+
min_theta = 1e-5
|
| 199 |
+
if w_last:
|
| 200 |
+
qx, qy, qz, qw = 0, 1, 2, 3
|
| 201 |
+
else:
|
| 202 |
+
qw, qx, qy, qz = 0, 1, 2, 3
|
| 203 |
+
|
| 204 |
+
sin_theta = torch.sqrt(1 - q[..., qw] * q[..., qw])
|
| 205 |
+
angle = 2 * torch.acos(q[..., qw])
|
| 206 |
+
angle = normalize_angle(angle)
|
| 207 |
+
sin_theta_expand = sin_theta.unsqueeze(-1)
|
| 208 |
+
axis = q[..., qx:qw] / sin_theta_expand
|
| 209 |
+
|
| 210 |
+
mask = torch.abs(sin_theta) > min_theta
|
| 211 |
+
default_axis = torch.zeros_like(axis)
|
| 212 |
+
default_axis[..., -1] = 1
|
| 213 |
+
|
| 214 |
+
angle = torch.where(mask, angle, torch.zeros_like(angle))
|
| 215 |
+
mask_expand = mask.unsqueeze(-1)
|
| 216 |
+
axis = torch.where(mask_expand, axis, default_axis)
|
| 217 |
+
return angle, axis
|
| 218 |
+
|
| 219 |
+
|
| 220 |
+
@torch.jit.script
|
| 221 |
+
def slerp(q0, q1, t):
|
| 222 |
+
# type: (Tensor, Tensor, Tensor) -> Tensor
|
| 223 |
+
cos_half_theta = torch.sum(q0 * q1, dim=-1)
|
| 224 |
+
|
| 225 |
+
neg_mask = cos_half_theta < 0
|
| 226 |
+
q1 = q1.clone()
|
| 227 |
+
|
| 228 |
+
# Replace: q1[neg_mask] = -q1[neg_mask]
|
| 229 |
+
# With: torch.where for safer broadcasting
|
| 230 |
+
neg_mask_expanded = neg_mask.unsqueeze(-1).expand_as(q1)
|
| 231 |
+
q1 = torch.where(neg_mask_expanded, -q1, q1)
|
| 232 |
+
|
| 233 |
+
cos_half_theta = torch.abs(cos_half_theta)
|
| 234 |
+
cos_half_theta = torch.unsqueeze(cos_half_theta, dim=-1)
|
| 235 |
+
|
| 236 |
+
half_theta = torch.acos(cos_half_theta)
|
| 237 |
+
sin_half_theta = torch.sqrt(1.0 - cos_half_theta * cos_half_theta)
|
| 238 |
+
|
| 239 |
+
ratioA = torch.sin((1 - t) * half_theta) / sin_half_theta
|
| 240 |
+
ratioB = torch.sin(t * half_theta) / sin_half_theta
|
| 241 |
+
|
| 242 |
+
new_q = ratioA * q0 + ratioB * q1
|
| 243 |
+
|
| 244 |
+
new_q = torch.where(torch.abs(sin_half_theta) < 0.001, 0.5 * q0 + 0.5 * q1, new_q)
|
| 245 |
+
new_q = torch.where(torch.abs(cos_half_theta) >= 1, q0, new_q)
|
| 246 |
+
|
| 247 |
+
return new_q
|
| 248 |
+
|
| 249 |
+
|
| 250 |
+
@torch.jit.script
|
| 251 |
+
def angle_axis_to_exp_map(angle, axis):
|
| 252 |
+
# type: (Tensor, Tensor) -> Tensor
|
| 253 |
+
# compute exponential map from axis-angle
|
| 254 |
+
angle_expand = angle.unsqueeze(-1)
|
| 255 |
+
exp_map = angle_expand * axis
|
| 256 |
+
return exp_map
|
| 257 |
+
|
| 258 |
+
|
| 259 |
+
@torch.jit.script
|
| 260 |
+
def my_quat_rotate(q, v, w_last=True):
|
| 261 |
+
# type: (Tensor, Tensor, bool) -> Tensor
|
| 262 |
+
shape = q.shape
|
| 263 |
+
if w_last:
|
| 264 |
+
q_w = q[:, -1]
|
| 265 |
+
q_vec = q[:, :3]
|
| 266 |
+
else:
|
| 267 |
+
q_w = q[:, 0]
|
| 268 |
+
q_vec = q[:, 1:]
|
| 269 |
+
a = v * (2.0 * q_w**2 - 1.0).unsqueeze(-1)
|
| 270 |
+
b = torch.cross(q_vec, v, dim=-1) * q_w.unsqueeze(-1) * 2.0
|
| 271 |
+
c = q_vec * torch.bmm(q_vec.view(shape[0], 1, 3), v.view(shape[0], 3, 1)).squeeze(-1) * 2.0
|
| 272 |
+
return a + b + c
|
| 273 |
+
|
| 274 |
+
|
| 275 |
+
@torch.jit.script
|
| 276 |
+
def quat_to_tan_norm(q, w_last):
|
| 277 |
+
# type: (Tensor, bool) -> Tensor
|
| 278 |
+
# represents a rotation using the tangent and normal vectors
|
| 279 |
+
ref_tan = torch.zeros_like(q[..., 0:3])
|
| 280 |
+
ref_tan[..., 0] = 1
|
| 281 |
+
if w_last:
|
| 282 |
+
tan = my_quat_rotate(q, ref_tan)
|
| 283 |
+
else:
|
| 284 |
+
raise NotImplementedError
|
| 285 |
+
|
| 286 |
+
ref_norm = torch.zeros_like(q[..., 0:3])
|
| 287 |
+
ref_norm[..., -1] = 1
|
| 288 |
+
if w_last:
|
| 289 |
+
norm = my_quat_rotate(q, ref_norm)
|
| 290 |
+
else:
|
| 291 |
+
raise NotImplementedError
|
| 292 |
+
|
| 293 |
+
norm_tan = torch.cat([tan, norm], dim=len(tan.shape) - 1)
|
| 294 |
+
return norm_tan
|
| 295 |
+
|
| 296 |
+
|
| 297 |
+
@torch.jit.script
|
| 298 |
+
def calc_heading(q, w_last=True):
|
| 299 |
+
# type: (Tensor, bool) -> Tensor
|
| 300 |
+
# calculate heading direction from quaternion
|
| 301 |
+
# the heading is the direction on the xy plane
|
| 302 |
+
# q must be normalized
|
| 303 |
+
# this is the x axis heading
|
| 304 |
+
ref_dir = torch.zeros_like(q[..., 0:3])
|
| 305 |
+
ref_dir[..., 0] = 1
|
| 306 |
+
rot_dir = my_quat_rotate(q, ref_dir, w_last)
|
| 307 |
+
|
| 308 |
+
heading = torch.atan2(rot_dir[..., 1], rot_dir[..., 0])
|
| 309 |
+
return heading
|
| 310 |
+
|
| 311 |
+
|
| 312 |
+
@torch.jit.script
|
| 313 |
+
def quat_to_exp_map(q, w_last):
|
| 314 |
+
# type: (Tensor, bool) -> Tensor
|
| 315 |
+
# compute exponential map from quaternion
|
| 316 |
+
# q must be normalized
|
| 317 |
+
angle, axis = quat_to_angle_axis(q, w_last)
|
| 318 |
+
exp_map = angle_axis_to_exp_map(angle, axis)
|
| 319 |
+
return exp_map
|
| 320 |
+
|
| 321 |
+
|
| 322 |
+
@torch.jit.script
|
| 323 |
+
def calc_heading_quat(q, w_last):
|
| 324 |
+
# type: (Tensor, bool) -> Tensor
|
| 325 |
+
# calculate heading rotation from quaternion
|
| 326 |
+
# the heading is the direction on the xy plane
|
| 327 |
+
# q must be normalized
|
| 328 |
+
heading = calc_heading(q, w_last)
|
| 329 |
+
axis = torch.zeros_like(q[..., 0:3])
|
| 330 |
+
axis[..., 2] = 1
|
| 331 |
+
|
| 332 |
+
heading_q = quat_from_angle_axis(heading, axis, w_last=w_last)
|
| 333 |
+
return heading_q
|
| 334 |
+
|
| 335 |
+
|
| 336 |
+
@torch.jit.script
|
| 337 |
+
def calc_heading_quat_inv(q, w_last):
|
| 338 |
+
# type: (Tensor, bool) -> Tensor
|
| 339 |
+
# calculate heading rotation from quaternion
|
| 340 |
+
# the heading is the direction on the xy plane
|
| 341 |
+
# q must be normalized
|
| 342 |
+
heading = calc_heading(q, w_last)
|
| 343 |
+
axis = torch.zeros_like(q[..., 0:3])
|
| 344 |
+
axis[..., 2] = 1
|
| 345 |
+
|
| 346 |
+
heading_q = quat_from_angle_axis(-heading, axis, w_last=w_last)
|
| 347 |
+
return heading_q
|
| 348 |
+
|
| 349 |
+
|
| 350 |
+
@torch.jit.script
|
| 351 |
+
def quat_inverse(x, w_last):
|
| 352 |
+
# type: (Tensor, bool) -> Tensor
|
| 353 |
+
"""
|
| 354 |
+
The inverse of the rotation
|
| 355 |
+
"""
|
| 356 |
+
return quat_conjugate(x, w_last=w_last)
|
| 357 |
+
|
| 358 |
+
|
| 359 |
+
@torch.jit.script
|
| 360 |
+
def get_euler_xyz(q: Tensor, w_last: bool) -> Tuple[Tensor, Tensor, Tensor]:
|
| 361 |
+
if w_last:
|
| 362 |
+
qx, qy, qz, qw = 0, 1, 2, 3
|
| 363 |
+
else:
|
| 364 |
+
qw, qx, qy, qz = 0, 1, 2, 3
|
| 365 |
+
# roll (x-axis rotation)
|
| 366 |
+
sinr_cosp = 2.0 * (q[:, qw] * q[:, qx] + q[:, qy] * q[:, qz])
|
| 367 |
+
cosr_cosp = (
|
| 368 |
+
q[:, qw] * q[:, qw] - q[:, qx] * q[:, qx] - q[:, qy] * q[:, qy] + q[:, qz] * q[:, qz]
|
| 369 |
+
)
|
| 370 |
+
roll = torch.atan2(sinr_cosp, cosr_cosp)
|
| 371 |
+
|
| 372 |
+
# pitch (y-axis rotation)
|
| 373 |
+
sinp = 2.0 * (q[:, qw] * q[:, qy] - q[:, qz] * q[:, qx])
|
| 374 |
+
pitch = torch.where(torch.abs(sinp) >= 1, copysign(np.pi / 2.0, sinp), torch.asin(sinp))
|
| 375 |
+
|
| 376 |
+
# yaw (z-axis rotation)
|
| 377 |
+
siny_cosp = 2.0 * (q[:, qw] * q[:, qz] + q[:, qx] * q[:, qy])
|
| 378 |
+
cosy_cosp = (
|
| 379 |
+
q[:, qw] * q[:, qw] + q[:, qx] * q[:, qx] - q[:, qy] * q[:, qy] - q[:, qz] * q[:, qz]
|
| 380 |
+
)
|
| 381 |
+
yaw = torch.atan2(siny_cosp, cosy_cosp)
|
| 382 |
+
|
| 383 |
+
return roll % (2 * np.pi), pitch % (2 * np.pi), yaw % (2 * np.pi)
|
| 384 |
+
|
| 385 |
+
|
| 386 |
+
# @torch.jit.script
|
| 387 |
+
def get_euler_xyz_in_tensor(q):
|
| 388 |
+
qx, qy, qz, qw = 0, 1, 2, 3
|
| 389 |
+
# roll (x-axis rotation)
|
| 390 |
+
sinr_cosp = 2.0 * (q[:, qw] * q[:, qx] + q[:, qy] * q[:, qz])
|
| 391 |
+
cosr_cosp = (
|
| 392 |
+
q[:, qw] * q[:, qw] - q[:, qx] * q[:, qx] - q[:, qy] * q[:, qy] + q[:, qz] * q[:, qz]
|
| 393 |
+
)
|
| 394 |
+
roll = torch.atan2(sinr_cosp, cosr_cosp)
|
| 395 |
+
|
| 396 |
+
# pitch (y-axis rotation)
|
| 397 |
+
sinp = 2.0 * (q[:, qw] * q[:, qy] - q[:, qz] * q[:, qx])
|
| 398 |
+
pitch = torch.where(torch.abs(sinp) >= 1, copysign(np.pi / 2.0, sinp), torch.asin(sinp))
|
| 399 |
+
|
| 400 |
+
# yaw (z-axis rotation)
|
| 401 |
+
siny_cosp = 2.0 * (q[:, qw] * q[:, qz] + q[:, qx] * q[:, qy])
|
| 402 |
+
cosy_cosp = (
|
| 403 |
+
q[:, qw] * q[:, qw] + q[:, qx] * q[:, qx] - q[:, qy] * q[:, qy] - q[:, qz] * q[:, qz]
|
| 404 |
+
)
|
| 405 |
+
yaw = torch.atan2(siny_cosp, cosy_cosp)
|
| 406 |
+
|
| 407 |
+
return torch.stack((roll, pitch, yaw), dim=-1)
|
| 408 |
+
|
| 409 |
+
|
| 410 |
+
@torch.jit.script
|
| 411 |
+
def quat_pos(x):
|
| 412 |
+
"""
|
| 413 |
+
make all the real part of the quaternion positive
|
| 414 |
+
"""
|
| 415 |
+
q = x
|
| 416 |
+
z = (q[..., 3:] < 0).float()
|
| 417 |
+
q = (1 - 2 * z) * q
|
| 418 |
+
return q
|
| 419 |
+
|
| 420 |
+
|
| 421 |
+
@torch.jit.script
|
| 422 |
+
def is_valid_quat(q):
|
| 423 |
+
x, y, z, w = q[..., 0], q[..., 1], q[..., 2], q[..., 3]
|
| 424 |
+
return (w * w + x * x + y * y + z * z).allclose(torch.ones_like(w))
|
| 425 |
+
|
| 426 |
+
|
| 427 |
+
@torch.jit.script
|
| 428 |
+
def quat_normalize(q):
|
| 429 |
+
"""
|
| 430 |
+
Construct 3D rotation from quaternion (the quaternion needs not to be normalized).
|
| 431 |
+
"""
|
| 432 |
+
q = quat_unit(quat_pos(q)) # normalized to positive and unit quaternion
|
| 433 |
+
return q
|
| 434 |
+
|
| 435 |
+
|
| 436 |
+
@torch.jit.script
|
| 437 |
+
def quat_mul(a, b, w_last: bool):
|
| 438 |
+
assert a.shape == b.shape
|
| 439 |
+
shape = a.shape
|
| 440 |
+
a = a.reshape(-1, 4)
|
| 441 |
+
b = b.reshape(-1, 4)
|
| 442 |
+
|
| 443 |
+
if w_last:
|
| 444 |
+
x1, y1, z1, w1 = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
|
| 445 |
+
x2, y2, z2, w2 = b[..., 0], b[..., 1], b[..., 2], b[..., 3]
|
| 446 |
+
else:
|
| 447 |
+
w1, x1, y1, z1 = a[..., 0], a[..., 1], a[..., 2], a[..., 3]
|
| 448 |
+
w2, x2, y2, z2 = b[..., 0], b[..., 1], b[..., 2], b[..., 3]
|
| 449 |
+
ww = (z1 + x1) * (x2 + y2)
|
| 450 |
+
yy = (w1 - y1) * (w2 + z2)
|
| 451 |
+
zz = (w1 + y1) * (w2 - z2)
|
| 452 |
+
xx = ww + yy + zz
|
| 453 |
+
qq = 0.5 * (xx + (z1 - x1) * (x2 - y2))
|
| 454 |
+
w = qq - ww + (z1 - y1) * (y2 - z2)
|
| 455 |
+
x = qq - xx + (x1 + w1) * (x2 + w2)
|
| 456 |
+
y = qq - yy + (w1 - x1) * (y2 + z2)
|
| 457 |
+
z = qq - zz + (z1 + y1) * (w2 - x2)
|
| 458 |
+
|
| 459 |
+
if w_last:
|
| 460 |
+
quat = torch.stack([x, y, z, w], dim=-1).view(shape)
|
| 461 |
+
else:
|
| 462 |
+
quat = torch.stack([w, x, y, z], dim=-1).view(shape)
|
| 463 |
+
|
| 464 |
+
return quat
|
| 465 |
+
|
| 466 |
+
|
| 467 |
+
|
| 468 |
+
@torch.jit.script
|
| 469 |
+
def quat_mul_norm(x, y, w_last):
|
| 470 |
+
# type: (Tensor, Tensor, bool) -> Tensor
|
| 471 |
+
"""
|
| 472 |
+
Combine two set of 3D rotations together using \**\* operator. The shape needs to be
|
| 473 |
+
broadcastable
|
| 474 |
+
"""
|
| 475 |
+
return quat_unit(quat_mul(x, y, w_last))
|
| 476 |
+
|
| 477 |
+
|
| 478 |
+
@torch.jit.script
|
| 479 |
+
def quat_identity(shape: List[int]):
|
| 480 |
+
"""
|
| 481 |
+
Construct 3D identity rotation given shape
|
| 482 |
+
"""
|
| 483 |
+
w = torch.ones(shape + [1])
|
| 484 |
+
xyz = torch.zeros(shape + [3])
|
| 485 |
+
q = torch.cat([xyz, w], dim=-1)
|
| 486 |
+
return quat_normalize(q)
|
| 487 |
+
|
| 488 |
+
|
| 489 |
+
@torch.jit.script
|
| 490 |
+
def quat_identity_like(x):
|
| 491 |
+
"""
|
| 492 |
+
Construct identity 3D rotation with the same shape
|
| 493 |
+
"""
|
| 494 |
+
return quat_identity(list(x.shape[:-1]))
|
| 495 |
+
|
| 496 |
+
|
| 497 |
+
@torch.jit.script
|
| 498 |
+
def transform_from_rotation_translation(
|
| 499 |
+
r: Optional[torch.Tensor] = None, t: Optional[torch.Tensor] = None
|
| 500 |
+
):
|
| 501 |
+
"""
|
| 502 |
+
Construct a transform from a quaternion and 3D translation. Only one of them can be None.
|
| 503 |
+
"""
|
| 504 |
+
assert r is not None or t is not None, "rotation and translation can't be all None"
|
| 505 |
+
if r is None:
|
| 506 |
+
assert t is not None
|
| 507 |
+
r = quat_identity(list(t.shape))
|
| 508 |
+
if t is None:
|
| 509 |
+
t = torch.zeros(list(r.shape) + [3])
|
| 510 |
+
return torch.cat([r, t], dim=-1)
|
| 511 |
+
|
| 512 |
+
|
| 513 |
+
@torch.jit.script
|
| 514 |
+
def transform_rotation(x):
|
| 515 |
+
"""Get rotation from transform"""
|
| 516 |
+
return x[..., :4]
|
| 517 |
+
|
| 518 |
+
|
| 519 |
+
@torch.jit.script
|
| 520 |
+
def transform_translation(x):
|
| 521 |
+
"""Get translation from transform"""
|
| 522 |
+
return x[..., 4:]
|
| 523 |
+
|
| 524 |
+
|
| 525 |
+
@torch.jit.script
|
| 526 |
+
def transform_mul(x, y):
|
| 527 |
+
"""
|
| 528 |
+
Combine two transformation together
|
| 529 |
+
"""
|
| 530 |
+
z = transform_from_rotation_translation(
|
| 531 |
+
r=quat_mul_norm(transform_rotation(x), transform_rotation(y), w_last=True),
|
| 532 |
+
t=quat_rotate(transform_rotation(x), transform_translation(y), w_last=True)
|
| 533 |
+
+ transform_translation(x),
|
| 534 |
+
)
|
| 535 |
+
return z
|
| 536 |
+
|
| 537 |
+
|
| 538 |
+
##################################### FROM PHC rotation_conversions.py #####################################
|
| 539 |
+
@torch.jit.script
|
| 540 |
+
def quaternion_to_matrix(quaternions: torch.Tensor) -> torch.Tensor:
|
| 541 |
+
"""
|
| 542 |
+
Convert rotations given as quaternions to rotation matrices.
|
| 543 |
+
|
| 544 |
+
Args:
|
| 545 |
+
quaternions: quaternions with real part first,
|
| 546 |
+
as tensor of shape (..., 4).
|
| 547 |
+
|
| 548 |
+
Returns:
|
| 549 |
+
Rotation matrices as tensor of shape (..., 3, 3).
|
| 550 |
+
"""
|
| 551 |
+
r, i, j, k = torch.unbind(quaternions, -1)
|
| 552 |
+
two_s = 2.0 / (quaternions * quaternions).sum(-1)
|
| 553 |
+
|
| 554 |
+
o = torch.stack(
|
| 555 |
+
(
|
| 556 |
+
1 - two_s * (j * j + k * k),
|
| 557 |
+
two_s * (i * j - k * r),
|
| 558 |
+
two_s * (i * k + j * r),
|
| 559 |
+
two_s * (i * j + k * r),
|
| 560 |
+
1 - two_s * (i * i + k * k),
|
| 561 |
+
two_s * (j * k - i * r),
|
| 562 |
+
two_s * (i * k - j * r),
|
| 563 |
+
two_s * (j * k + i * r),
|
| 564 |
+
1 - two_s * (i * i + j * j),
|
| 565 |
+
),
|
| 566 |
+
-1,
|
| 567 |
+
)
|
| 568 |
+
return o.reshape(quaternions.shape[:-1] + (3, 3))
|
| 569 |
+
|
| 570 |
+
|
| 571 |
+
@torch.jit.script
|
| 572 |
+
def axis_angle_to_quaternion(axis_angle: torch.Tensor) -> torch.Tensor:
|
| 573 |
+
"""
|
| 574 |
+
Convert rotations given as axis/angle to quaternions.
|
| 575 |
+
|
| 576 |
+
Args:
|
| 577 |
+
axis_angle: Rotations given as a vector in axis angle form,
|
| 578 |
+
as a tensor of shape (..., 3), where the magnitude is
|
| 579 |
+
the angle turned anticlockwise in radians around the
|
| 580 |
+
vector's direction.
|
| 581 |
+
|
| 582 |
+
Returns:
|
| 583 |
+
quaternions with real part first, as tensor of shape (..., 4).
|
| 584 |
+
"""
|
| 585 |
+
angles = torch.norm(axis_angle, p=2, dim=-1, keepdim=True)
|
| 586 |
+
half_angles = angles * 0.5
|
| 587 |
+
eps = 1e-6
|
| 588 |
+
small_angles = angles.abs() < eps
|
| 589 |
+
sin_half_angles_over_angles = torch.empty_like(angles)
|
| 590 |
+
sin_half_angles_over_angles[~small_angles] = (
|
| 591 |
+
torch.sin(half_angles[~small_angles]) / angles[~small_angles]
|
| 592 |
+
)
|
| 593 |
+
# for x small, sin(x/2) is about x/2 - (x/2)^3/6
|
| 594 |
+
# so sin(x/2)/x is about 1/2 - (x*x)/48
|
| 595 |
+
sin_half_angles_over_angles[small_angles] = (
|
| 596 |
+
0.5 - (angles[small_angles] * angles[small_angles]) / 48
|
| 597 |
+
)
|
| 598 |
+
quaternions = torch.cat(
|
| 599 |
+
[torch.cos(half_angles), axis_angle * sin_half_angles_over_angles], dim=-1
|
| 600 |
+
)
|
| 601 |
+
return quaternions
|
| 602 |
+
|
| 603 |
+
|
| 604 |
+
# @torch.jit.script
|
| 605 |
+
def wxyz_to_xyzw(quat):
|
| 606 |
+
return quat[..., [1, 2, 3, 0]]
|
| 607 |
+
|
| 608 |
+
|
| 609 |
+
# @torch.jit.script
|
| 610 |
+
def xyzw_to_wxyz(quat):
|
| 611 |
+
return quat[..., [3, 0, 1, 2]]
|
| 612 |
+
|
| 613 |
+
|
| 614 |
+
def matrix_to_quaternion(matrix: torch.Tensor) -> torch.Tensor:
|
| 615 |
+
"""
|
| 616 |
+
w x y z
|
| 617 |
+
Convert rotations given as rotation matrices to quaternions.
|
| 618 |
+
|
| 619 |
+
Args:
|
| 620 |
+
matrix: Rotation matrices as tensor of shape (..., 3, 3).
|
| 621 |
+
|
| 622 |
+
Returns:
|
| 623 |
+
quaternions with real part first, as tensor of shape (..., 4).
|
| 624 |
+
"""
|
| 625 |
+
if matrix.size(-1) != 3 or matrix.size(-2) != 3:
|
| 626 |
+
raise ValueError(f"Invalid rotation matrix shape {matrix.shape}.")
|
| 627 |
+
|
| 628 |
+
batch_dim = matrix.shape[:-2]
|
| 629 |
+
m00, m01, m02, m10, m11, m12, m20, m21, m22 = torch.unbind(
|
| 630 |
+
matrix.reshape(batch_dim + (9,)), dim=-1
|
| 631 |
+
)
|
| 632 |
+
|
| 633 |
+
q_abs = _sqrt_positive_part(
|
| 634 |
+
torch.stack(
|
| 635 |
+
[
|
| 636 |
+
1.0 + m00 + m11 + m22,
|
| 637 |
+
1.0 + m00 - m11 - m22,
|
| 638 |
+
1.0 - m00 + m11 - m22,
|
| 639 |
+
1.0 - m00 - m11 + m22,
|
| 640 |
+
],
|
| 641 |
+
dim=-1,
|
| 642 |
+
)
|
| 643 |
+
)
|
| 644 |
+
|
| 645 |
+
# we produce the desired quaternion multiplied by each of r, i, j, k
|
| 646 |
+
quat_by_rijk = torch.stack(
|
| 647 |
+
[
|
| 648 |
+
torch.stack([q_abs[..., 0] ** 2, m21 - m12, m02 - m20, m10 - m01], dim=-1),
|
| 649 |
+
torch.stack([m21 - m12, q_abs[..., 1] ** 2, m10 + m01, m02 + m20], dim=-1),
|
| 650 |
+
torch.stack([m02 - m20, m10 + m01, q_abs[..., 2] ** 2, m12 + m21], dim=-1),
|
| 651 |
+
torch.stack([m10 - m01, m20 + m02, m21 + m12, q_abs[..., 3] ** 2], dim=-1),
|
| 652 |
+
],
|
| 653 |
+
dim=-2,
|
| 654 |
+
)
|
| 655 |
+
|
| 656 |
+
# We floor here at 0.1 but the exact level is not important; if q_abs is small,
|
| 657 |
+
# the candidate won't be picked.
|
| 658 |
+
flr = torch.tensor(0.1).to(dtype=q_abs.dtype, device=q_abs.device)
|
| 659 |
+
quat_candidates = quat_by_rijk / (2.0 * q_abs[..., None].max(flr))
|
| 660 |
+
|
| 661 |
+
# if not for numerical problems, quat_candidates[i] should be same (up to a sign),
|
| 662 |
+
# forall i; we pick the best-conditioned one (with the largest denominator)
|
| 663 |
+
|
| 664 |
+
return quat_candidates[
|
| 665 |
+
F.one_hot(q_abs.argmax(dim=-1), num_classes=4) > 0.5, : # pyre-ignore[16]
|
| 666 |
+
].reshape(batch_dim + (4,))
|
| 667 |
+
|
| 668 |
+
|
| 669 |
+
def _sqrt_positive_part(x: torch.Tensor) -> torch.Tensor:
|
| 670 |
+
"""
|
| 671 |
+
Returns torch.sqrt(torch.max(0, x))
|
| 672 |
+
but with a zero subgradient where x is 0.
|
| 673 |
+
"""
|
| 674 |
+
ret = torch.zeros_like(x)
|
| 675 |
+
positive_mask = x > 0
|
| 676 |
+
ret[positive_mask] = torch.sqrt(x[positive_mask])
|
| 677 |
+
return ret
|
| 678 |
+
|
| 679 |
+
|
| 680 |
+
def quat_w_first(rot):
|
| 681 |
+
rot = torch.cat([rot[..., [-1]], rot[..., :-1]], -1)
|
| 682 |
+
return rot
|
| 683 |
+
|
| 684 |
+
|
| 685 |
+
@torch.jit.script
|
| 686 |
+
def quat_from_euler_xyz(roll, pitch, yaw):
|
| 687 |
+
cy = torch.cos(yaw * 0.5)
|
| 688 |
+
sy = torch.sin(yaw * 0.5)
|
| 689 |
+
cr = torch.cos(roll * 0.5)
|
| 690 |
+
sr = torch.sin(roll * 0.5)
|
| 691 |
+
cp = torch.cos(pitch * 0.5)
|
| 692 |
+
sp = torch.sin(pitch * 0.5)
|
| 693 |
+
|
| 694 |
+
qw = cy * cr * cp + sy * sr * sp
|
| 695 |
+
qx = cy * sr * cp - sy * cr * sp
|
| 696 |
+
qy = cy * cr * sp + sy * sr * cp
|
| 697 |
+
qz = sy * cr * cp - cy * sr * sp
|
| 698 |
+
|
| 699 |
+
return torch.stack([qx, qy, qz, qw], dim=-1)
|
| 700 |
+
|
| 701 |
+
|
| 702 |
+
|
| 703 |
+
@torch.jit.script
|
| 704 |
+
def remove_smpl_base_rot(quat, w_last: bool):
|
| 705 |
+
# [0.5,0.5,0.5,0.5] is a 120° rotation about the [1,1,1] axis — SMPL's default rest orientation.
|
| 706 |
+
# Conjugating it out aligns with a neutral standing pose.
|
| 707 |
+
base_rot = quat_conjugate(torch.tensor([[0.5, 0.5, 0.5, 0.5]]).to(quat), w_last=w_last) # SMPL
|
| 708 |
+
return quat_mul(quat, base_rot.repeat(quat.shape[0], 1), w_last=w_last)
|
| 709 |
+
|
| 710 |
+
|
| 711 |
+
@torch.jit.script
|
| 712 |
+
def smpl_root_ytoz_up(root_quat_y_up) -> torch.Tensor:
|
| 713 |
+
"""Convert SMPL root quaternion from Y-up to Z-up coordinate system"""
|
| 714 |
+
# 90° rotation about X-axis maps Y-up (SMPL convention) to Z-up (robot convention)
|
| 715 |
+
base_rot = angle_axis_to_quaternion(torch.tensor([[np.pi / 2, 0.0, 0.0]]).to(root_quat_y_up))
|
| 716 |
+
root_quat_z_up = quat_mul(
|
| 717 |
+
base_rot.repeat(root_quat_y_up.shape[0], 1), root_quat_y_up, w_last=False
|
| 718 |
+
)
|
| 719 |
+
return root_quat_z_up
|
| 720 |
+
|
| 721 |
+
|
| 722 |
+
@torch.jit.script
|
| 723 |
+
def rotate_vectors_by_quaternion(quat: torch.Tensor, vec: torch.Tensor) -> torch.Tensor:
|
| 724 |
+
"""
|
| 725 |
+
Rotate `vec` by `quat`, elementwise.
|
| 726 |
+
|
| 727 |
+
Args:
|
| 728 |
+
quat (torch.Tensor): Tensor of shape (..., 4), quaternions in [x, y, z, w] format.
|
| 729 |
+
vec (torch.Tensor): Tensor of shape (..., 3), vectors to rotate.
|
| 730 |
+
|
| 731 |
+
Returns:
|
| 732 |
+
torch.Tensor: Rotated vectors, same shape as `vec`.
|
| 733 |
+
"""
|
| 734 |
+
q_xyz = quat[..., :3] # (..., 3)
|
| 735 |
+
q_w = quat[..., 3:].unsqueeze(-1) # (..., 1, 1) -> we'll squeeze to (...,1)
|
| 736 |
+
|
| 737 |
+
# Compute intermediate cross products
|
| 738 |
+
# t = 2 * q_xyz × v
|
| 739 |
+
t = 2.0 * torch.cross(q_xyz, vec, dim=-1) # (..., 3)
|
| 740 |
+
|
| 741 |
+
# v' = v + w * t + q_xyz × t
|
| 742 |
+
rotated = vec + q_w.squeeze(-1) * t + torch.cross(q_xyz, t, dim=-1)
|
| 743 |
+
return rotated
|
| 744 |
+
|
| 745 |
+
|
| 746 |
+
def rot6d_to_quat_first_two_cols(rot_6d: torch.Tensor) -> torch.Tensor:
|
| 747 |
+
"""
|
| 748 |
+
Convert 6D rotation representation (first 2 columns of rotation matrix) to quaternion.
|
| 749 |
+
|
| 750 |
+
This function handles the 6D representation where the first 6 elements represent
|
| 751 |
+
the first 2 columns of a 3x3 rotation matrix (flattened). The third column is
|
| 752 |
+
reconstructed via cross product of the first two columns.
|
| 753 |
+
|
| 754 |
+
Args:
|
| 755 |
+
rot_6d (torch.Tensor): Tensor of shape (..., 6) representing the first 2 columns
|
| 756 |
+
of rotation matrix flattened.
|
| 757 |
+
|
| 758 |
+
Returns:
|
| 759 |
+
torch.Tensor: Quaternion in (w, x, y, z) format, shape (..., 4).
|
| 760 |
+
"""
|
| 761 |
+
# Reshape to get first 2 columns: (..., 3, 2)
|
| 762 |
+
rot_2cols = rot_6d.reshape(*rot_6d.shape[:-1], 3, 2)
|
| 763 |
+
|
| 764 |
+
# Extract the two column vectors
|
| 765 |
+
col_0 = rot_2cols[..., :, 0]
|
| 766 |
+
col_1 = rot_2cols[..., :, 1]
|
| 767 |
+
|
| 768 |
+
# Normalize the columns to ensure they are unit vectors
|
| 769 |
+
col_0 = F.normalize(col_0, dim=-1)
|
| 770 |
+
col_1 = F.normalize(col_1, dim=-1)
|
| 771 |
+
|
| 772 |
+
# Reconstruct the third column via cross product
|
| 773 |
+
col_2 = torch.cross(col_0, col_1, dim=-1)
|
| 774 |
+
|
| 775 |
+
# Stack to form full rotation matrix (..., 3, 3)
|
| 776 |
+
rot_matrix = torch.stack([col_0, col_1, col_2], dim=-1)
|
| 777 |
+
|
| 778 |
+
# Convert rotation matrix to quaternion (w, x, y, z format)
|
| 779 |
+
quat = matrix_to_quaternion(rot_matrix)
|
| 780 |
+
|
| 781 |
+
return quat
|
| 782 |
+
|
| 783 |
+
|
| 784 |
+
def remove_bvh_base_rot(quat, w_last: bool):
|
| 785 |
+
"""Remove BVH base rotation. BVH base = conj(SMPL base), so conj(BVH base) = SMPL base."""
|
| 786 |
+
base_rot = torch.tensor([[0.5, 0.5, 0.5, 0.5]]).to(quat)
|
| 787 |
+
return quat_mul(quat, base_rot.repeat(quat.shape[0], 1), w_last=w_last)
|
GR00T-WholeBodyControl/gear_sonic/utils/__init__.py
ADDED
|
File without changes
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/__init__.cpython-310.pyc
ADDED
|
Binary file (159 Bytes). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/__init__.cpython-311.pyc
ADDED
|
Binary file (175 Bytes). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/__init__.cpython-312.pyc
ADDED
|
Binary file (163 Bytes). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/average_meters.cpython-311.pyc
ADDED
|
Binary file (9.29 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/average_meters.cpython-312.pyc
ADDED
|
Binary file (8.54 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/batch_normalizer.cpython-311.pyc
ADDED
|
Binary file (3.84 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/batch_normalizer.cpython-312.pyc
ADDED
|
Binary file (3.56 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/common.cpython-311.pyc
ADDED
|
Binary file (7.86 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/common.cpython-312.pyc
ADDED
|
Binary file (7.45 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/config_utils.cpython-311.pyc
ADDED
|
Binary file (2.81 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/config_utils.cpython-312.pyc
ADDED
|
Binary file (2.59 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/logging.cpython-311.pyc
ADDED
|
Binary file (3.51 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/logging.cpython-312.pyc
ADDED
|
Binary file (3.3 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/obs_utils.cpython-311.pyc
ADDED
|
Binary file (4.57 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/obs_utils.cpython-312.pyc
ADDED
|
Binary file (4.09 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/running_mean_std.cpython-311.pyc
ADDED
|
Binary file (18.3 kB). View file
|
|
|
GR00T-WholeBodyControl/gear_sonic/utils/__pycache__/running_mean_std.cpython-312.pyc
ADDED
|
Binary file (16.9 kB). View file
|
|
|