add convert to LeRobot
This commit is contained in:
parent
e45a00d664
commit
000dc1cc08
2 changed files with 387 additions and 0 deletions
11
rbs_utils/rbs_utils/scripts/convert_rosbag_to_lerobot.md
Normal file
11
rbs_utils/rbs_utils/scripts/convert_rosbag_to_lerobot.md
Normal file
|
@ -0,0 +1,11 @@
|
|||
## Скрипт конвертации записанных эпизодов (rosbag) в формат LeRobot-датасет.
|
||||
Команда запуска:
|
||||
```bash
|
||||
python convert_rosbag_to_lerobot.py /path/to/rosbag/episodes
|
||||
```
|
||||
При успешном выполнении в рабочей папке образуются два json-файла с данными эпизодов:
|
||||
- ros2bag_msg.json
|
||||
- ros2bag_msg_synced.json
|
||||
|
||||
Файлы изображений сохраняются в папке 'frames' по эпизодам.
|
||||
И, наконец, датасет в формате LeRobot сохраняется в папку 'converted_dataset'.
|
376
rbs_utils/rbs_utils/scripts/convert_rosbag_to_lerobot.py
Normal file
376
rbs_utils/rbs_utils/scripts/convert_rosbag_to_lerobot.py
Normal file
|
@ -0,0 +1,376 @@
|
|||
import os
|
||||
import sys
|
||||
import json
|
||||
import argparse
|
||||
from typing import List
|
||||
from pathlib import Path
|
||||
from PIL import Image
|
||||
import numpy as np
|
||||
import cv2
|
||||
import time
|
||||
|
||||
from cv_bridge import CvBridge
|
||||
from rosbags.highlevel import AnyReader
|
||||
from rosbags.typesys import Stores, get_typestore
|
||||
|
||||
from lerobot.common.datasets.lerobot_dataset import LeRobotDataset
|
||||
|
||||
FPS = 30 # default
|
||||
REPO_ID = "rbs_ros2bag"
|
||||
OUTPUT_ROOT = "./converted_dataset"
|
||||
USE_VIDEOS = False # используем PNG-фреймы, так что формат — изображения
|
||||
|
||||
start_time = time.time() # Запоминаем время начала
|
||||
|
||||
def convert_seconds(total_seconds) -> str:
|
||||
hours = int(total_seconds // 3600)
|
||||
minutes = int((total_seconds % 3600) // 60)
|
||||
seconds = int(total_seconds % 60)
|
||||
return f"{hours:02}:{minutes:02}:{seconds:02}"
|
||||
|
||||
def has_db3_file(directory) -> bool:
|
||||
return any(file.suffix == '.db3' for file in Path(directory).iterdir() if file.is_file())
|
||||
|
||||
def find_folders_with_db3_files(directory: Path) -> List[Path]:
|
||||
# Получаем все вложенные папки
|
||||
subfolders = [folder for folder in directory.iterdir() if folder.is_dir() and has_db3_file(folder)]
|
||||
# Сортируем список папок
|
||||
return sorted(subfolders)
|
||||
|
||||
def to_lerobot_dataset(json_file: Path):
|
||||
# === ЗАГРУЗКА ДАННЫХ ===
|
||||
with open(json_file, "r") as f:
|
||||
data = json.load(f)
|
||||
|
||||
camera_keys = data["cameras"]
|
||||
image_shape = data["image_shape"]
|
||||
|
||||
cam_features = [cam.lstrip('/').replace('/', '.') for cam in camera_keys] # ['/robot_camera/depth_image', '/robot_camera/image']
|
||||
|
||||
robot_joint_names = data["episodes"][0]["frames"][0]["joint_state"]["name"]
|
||||
nof_joints = len(robot_joint_names)
|
||||
estimated_fps = data["estimated_fps"]
|
||||
fps = FPS if estimated_fps < 0.1 else estimated_fps
|
||||
|
||||
# === ОПРЕДЕЛЕНИЕ FEATURES ===
|
||||
features = {
|
||||
cam: {
|
||||
"dtype": "image",
|
||||
"shape": image_shape,
|
||||
"names": ["height", "width", "channels"]
|
||||
}
|
||||
for cam in cam_features
|
||||
}
|
||||
features.update({
|
||||
"observation.state": {
|
||||
"dtype": "float32",
|
||||
"shape": (nof_joints,),
|
||||
"names": robot_joint_names,
|
||||
},
|
||||
})
|
||||
# features.update({
|
||||
# "joint_position": {
|
||||
# "dtype": "float32",
|
||||
# "shape": (nof_joints,),
|
||||
# "names": robot_joint_names,
|
||||
# },
|
||||
# "joint_velocity": {
|
||||
# "dtype": "float32",
|
||||
# "shape": (nof_joints,),
|
||||
# "names": robot_joint_names,
|
||||
# },
|
||||
# "joint_effort": {
|
||||
# "dtype": "float32",
|
||||
# "shape": (nof_joints,),
|
||||
# "names": robot_joint_names,
|
||||
# },
|
||||
# })
|
||||
features.update({
|
||||
"action": {
|
||||
"dtype": "float32",
|
||||
"shape": (nof_joints,),
|
||||
"names": robot_joint_names,
|
||||
},
|
||||
})
|
||||
|
||||
# === СОЗДАНИЕ LeRobotDataset ===
|
||||
dataset = LeRobotDataset.create(
|
||||
repo_id=REPO_ID,
|
||||
fps=fps,
|
||||
root=OUTPUT_ROOT,
|
||||
features=features,
|
||||
use_videos=USE_VIDEOS,
|
||||
)
|
||||
|
||||
for episode in data["episodes"]:
|
||||
# === КОНВЕРТАЦИЯ ФРЕЙМОВ ===
|
||||
for frame in episode["frames"]:
|
||||
frame_data = {
|
||||
# "timestamp": np.array((idx / fps,), dtype=np.float32),
|
||||
# "timestamp": np.array([idx / fps], dtype=np.float32),
|
||||
"task": "default_task",
|
||||
"observation.state": np.array(frame["joint_state"]["pos"], dtype=np.float32),
|
||||
"action": np.array(frame["joint_state"]["pos"], dtype=np.float32),
|
||||
# "joint_position": np.array(frame["joint_state"]["pos"], dtype=np.float32),
|
||||
# "joint_velocity": np.array(frame["joint_state"]["vel"], dtype=np.float32),
|
||||
# "joint_effort": np.array(frame["joint_state"]["eff"], dtype=np.float32),
|
||||
}
|
||||
# print(f"{idx=}) {frame_data['timestamp'].shape=} {frame_data['timestamp']=}")
|
||||
|
||||
for cam_idx, cam in enumerate(camera_keys):
|
||||
image_path = Path(frame[cam])
|
||||
image = Image.open(image_path).convert("RGB")
|
||||
frame_data[cam_features[cam_idx]] = np.array(image)
|
||||
|
||||
dataset.add_frame(frame_data)
|
||||
|
||||
# === СОХРАНЕНИЕ ЭПИЗОДА ===
|
||||
dataset.save_episode()
|
||||
|
||||
def add_episode(dir: Path) -> None:
|
||||
image_shape = None
|
||||
episode = {
|
||||
"bag": str(dir.resolve()),
|
||||
"eidx": add_episode.counter,
|
||||
"num_joint_state": 0,
|
||||
"joint_states": [],
|
||||
"num_frame": 0,
|
||||
"frames": []
|
||||
}
|
||||
e_image_dir = "episode" + str(add_episode.counter+1) # folder for images of the episode
|
||||
with AnyReader([dir], default_typestore=extract_rosbag_to_json.typestore) as reader:
|
||||
camera_topics = [] # for update
|
||||
frame_index = -1
|
||||
start_topic = ""
|
||||
num_js = 0
|
||||
for conn, timestamp, rawdata in reader.messages():
|
||||
topic = conn.topic
|
||||
msg = reader.deserialize(rawdata, conn.msgtype)
|
||||
|
||||
if topic == extract_rosbag_to_json.joint_topic:
|
||||
def clean_array(arr):
|
||||
return [0.0 if isinstance(v, float) and (v != v) else v for v in arr] # NaN != NaN
|
||||
|
||||
num_js += 1
|
||||
episode["joint_states"].append({
|
||||
"timestamp": timestamp,
|
||||
"joint_state": {
|
||||
"name": list(msg.name),
|
||||
"pos": clean_array(list(msg.position)),
|
||||
"vel": clean_array(list(msg.velocity)),
|
||||
"eff": clean_array(list(msg.effort)),
|
||||
}
|
||||
})
|
||||
|
||||
elif topic in extract_rosbag_to_json.camera_topics:
|
||||
try:
|
||||
if topic in extract_rosbag_to_json.cim_topic:
|
||||
img_cv = extract_rosbag_to_json.bridge.compressed_imgmsg_to_cv2(msg)
|
||||
else:
|
||||
img_cv = extract_rosbag_to_json.bridge.imgmsg_to_cv2(msg) #, desired_encoding="passthrough")
|
||||
|
||||
if img_cv is None:
|
||||
continue
|
||||
elif len(img_cv.shape) == 2:
|
||||
img_cv = cv2.cvtColor(img_cv, cv2.COLOR_GRAY2RGB)
|
||||
elif img_cv.shape[2] == 4:
|
||||
img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGRA2RGB)
|
||||
else:
|
||||
img_cv = cv2.cvtColor(img_cv, cv2.COLOR_BGR2RGB)
|
||||
|
||||
if frame_index < 0:
|
||||
start_topic = topic
|
||||
if topic == start_topic:
|
||||
if image_shape == None:
|
||||
image_shape = img_cv.shape
|
||||
frame_index += 1
|
||||
|
||||
camera_name = topic.lstrip('/').replace('/', '_')
|
||||
img_filename = f"{e_image_dir}/{camera_name}/frame_{frame_index:06d}.png"
|
||||
img_path = extract_rosbag_to_json.image_dir / img_filename
|
||||
img_path.parent.parent.mkdir(exist_ok=True)
|
||||
img_path.parent.mkdir(exist_ok=True)
|
||||
Image.fromarray(img_cv).save(img_path)
|
||||
|
||||
episode["frames"].append({
|
||||
"timestamp": timestamp,
|
||||
topic: str(img_path) #.relative_to(Path.cwd())) # str(img_filename)
|
||||
})
|
||||
if topic not in camera_topics:
|
||||
camera_topics.append(topic)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[!] Failed to process image from {topic} @ {timestamp}: {img_cv=} {e}")
|
||||
|
||||
if len(episode["frames"]):
|
||||
# only those topics will remain for which there are frames
|
||||
print(f"{camera_topics=}")
|
||||
if len(camera_topics) < len(extract_rosbag_to_json.camera_topics):
|
||||
extract_rosbag_to_json.camera_topics = camera_topics
|
||||
extract_rosbag_to_json.output["cameras"] = camera_topics
|
||||
|
||||
episode["num_frame"] = frame_index + 1
|
||||
episode["num_joint_state"] = num_js
|
||||
extract_rosbag_to_json.output["episodes"].append(episode)
|
||||
extract_rosbag_to_json.image_shape = image_shape # applies to the entire dataset
|
||||
|
||||
add_episode.counter += 1
|
||||
|
||||
def common_part_json(dir: Path) -> None:
|
||||
# common part
|
||||
with AnyReader([dir], default_typestore=extract_rosbag_to_json.typestore) as reader:
|
||||
# === Определяем топики ===
|
||||
for conn in reader.connections:
|
||||
if conn.msgtype == "sensor_msgs/msg/Image":
|
||||
extract_rosbag_to_json.im_topic.append(conn.topic)
|
||||
elif conn.msgtype == "sensor_msgs/msg/CompressedImage":
|
||||
extract_rosbag_to_json.cim_topic.append(conn.topic)
|
||||
|
||||
# all camera topics
|
||||
camera_topics = extract_rosbag_to_json.im_topic + extract_rosbag_to_json.cim_topic
|
||||
# find only camera topics with data
|
||||
active_topics = {}
|
||||
for conn, _, _ in reader.messages():
|
||||
if conn.topic in camera_topics:
|
||||
active_topics[conn.topic] = None
|
||||
camera_topics = [t for t in active_topics.keys()]
|
||||
|
||||
extract_rosbag_to_json.joint_topic = next(
|
||||
(conn.topic for conn in reader.connections if conn.msgtype == "sensor_msgs/msg/JointState"), None
|
||||
)
|
||||
if not extract_rosbag_to_json.joint_topic:
|
||||
raise ValueError("JointState topic not found in bag")
|
||||
|
||||
# === Структура JSON ===
|
||||
extract_rosbag_to_json.output = {
|
||||
"cameras": camera_topics,
|
||||
"image_shape": [],
|
||||
"robots": [extract_rosbag_to_json.joint_topic],
|
||||
"num_episodes": 0,
|
||||
"episodes": []
|
||||
}
|
||||
extract_rosbag_to_json.camera_topics = camera_topics
|
||||
|
||||
def extract_rosbag_to_json(bag_path, output_json, output_image_dir) -> Path:
|
||||
print("Starting the export procedure..")
|
||||
|
||||
bag_path = Path(bag_path)
|
||||
output_json = Path(output_json)
|
||||
extract_rosbag_to_json.image_dir = Path(output_image_dir)
|
||||
extract_rosbag_to_json.image_dir.mkdir(exist_ok=True, parents=True)
|
||||
|
||||
extract_rosbag_to_json.bridge = CvBridge()
|
||||
|
||||
extract_rosbag_to_json.typestore = get_typestore(Stores.ROS2_JAZZY)
|
||||
|
||||
extract_rosbag_to_json.output = {}
|
||||
extract_rosbag_to_json.im_topic = []
|
||||
extract_rosbag_to_json.cim_topic = []
|
||||
extract_rosbag_to_json.camera_topics = []
|
||||
extract_rosbag_to_json.joint_topic = []
|
||||
extract_rosbag_to_json.image_shape = None
|
||||
|
||||
list_bags = find_folders_with_db3_files(bag_path)
|
||||
common_part_json(list_bags[0])
|
||||
|
||||
add_episode.counter = 0
|
||||
|
||||
for bag in list_bags:
|
||||
add_episode(bag)
|
||||
print(f"episode {add_episode.counter}: ok")
|
||||
|
||||
extract_rosbag_to_json.output["image_shape"] = extract_rosbag_to_json.image_shape
|
||||
extract_rosbag_to_json.output["num_episodes"] = len(list_bags) #add_episode.counter
|
||||
|
||||
with open(output_json, "w") as f:
|
||||
json.dump(extract_rosbag_to_json.output, f, indent=2)
|
||||
|
||||
print(f"✅ Export complete!\nJSON saved to: {output_json.resolve()}\nImages saved to: {extract_rosbag_to_json.image_dir.resolve()}")
|
||||
|
||||
# === СОХРАНЕНИЕ СИНХРОНИЗИРОВАННОГО ВАРИАНТА ===
|
||||
fps = 0.0
|
||||
synced_output = {
|
||||
"bags": str(bag_path.resolve()),
|
||||
"estimated_fps": fps,
|
||||
"cameras": extract_rosbag_to_json.camera_topics,
|
||||
"image_shape": extract_rosbag_to_json.image_shape,
|
||||
"robots": [extract_rosbag_to_json.joint_topic],
|
||||
"num_episodes": len(list_bags),
|
||||
"episodes": []
|
||||
}
|
||||
rgb_topic = extract_rosbag_to_json.camera_topics[0]
|
||||
|
||||
for episode in extract_rosbag_to_json.output["episodes"]:
|
||||
e_sync = {
|
||||
"bag": episode["bag"],
|
||||
"eidx": episode["eidx"],
|
||||
"num_frames": 0,
|
||||
"frames": []
|
||||
}
|
||||
|
||||
rgb_msgs = [m for m in episode["frames"] if rgb_topic in m]
|
||||
joint_msgs = [m for m in episode["joint_states"]]
|
||||
|
||||
num_frames = 0
|
||||
for im in rgb_msgs:
|
||||
# Находим ближайшее joint_state сообщение по timestamp
|
||||
closest = min(joint_msgs, key=lambda j: abs(j["timestamp"] - im["timestamp"]))
|
||||
|
||||
# Добавляем все изображения (включая rgb_topic и другие камеры)
|
||||
cam_images = {}
|
||||
for cam_topic in extract_rosbag_to_json.camera_topics:
|
||||
# ищем изображение от этой камеры с таким же timestamp
|
||||
candidates = [m for m in episode["frames"] if cam_topic in m]
|
||||
match = min(candidates, key=lambda m: abs(m["timestamp"] - im["timestamp"])) if candidates else None
|
||||
if match:
|
||||
cam_key = cam_topic
|
||||
cam_images[cam_key] = match[cam_topic]
|
||||
|
||||
e_sync["frames"].append({
|
||||
"timestamp": im["timestamp"],
|
||||
"idx": num_frames,
|
||||
"joint_state": closest["joint_state"],
|
||||
**cam_images # добавляем все камеры
|
||||
})
|
||||
num_frames += 1
|
||||
|
||||
e_sync["num_frames"] = num_frames
|
||||
|
||||
if num_frames >= 2 and fps <= 0.0:
|
||||
t0 = e_sync["frames"][0]["timestamp"]
|
||||
t1 = e_sync["frames"][-1]["timestamp"]
|
||||
duration_sec = (t1 - t0) / 1e9 # наносекунды → секунды
|
||||
fps = (num_frames - 1) / duration_sec if duration_sec > 0 else 0.0
|
||||
synced_output["estimated_fps"] = fps
|
||||
print(f"📈 Estimated FPS (synced): {fps:.1f}")
|
||||
|
||||
synced_output["episodes"].append(e_sync)
|
||||
|
||||
synced_json_path = output_json.with_name(output_json.stem + "_synced.json")
|
||||
with open(synced_json_path, "w") as f:
|
||||
json.dump(synced_output, f, indent=2)
|
||||
|
||||
print(f"🧩 Synced JSON saved to: {synced_json_path.resolve()}")
|
||||
return synced_json_path
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description="Convert ROS2 bag to JSON + image folder (LeRobot format)")
|
||||
parser.add_argument("bag", help="Path to folder with ROS2 bag episode files")
|
||||
parser.add_argument("--json", default="ros2bag_msg.json", help="Path to output JSON file")
|
||||
parser.add_argument("--images", default="frames", help="Directory to store extracted images")
|
||||
args = parser.parse_args()
|
||||
|
||||
if not os.path.isdir(args.bag):
|
||||
print(f"[!] {args.bag} is not a folder")
|
||||
sys.exit()
|
||||
|
||||
out_json = extract_rosbag_to_json(args.bag, args.json, args.images)
|
||||
to_lerobot_dataset(out_json)
|
||||
|
||||
end_time = time.time() # время окончания
|
||||
execution_time = end_time - start_time
|
||||
print(f"*****************\nВремя выполнения: {convert_seconds(execution_time)}\n")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
Loading…
Add table
Add a link
Reference in a new issue