blob: aa78e1ca1b0c6f72552951e61f36f0be714107c4 [file]
# Licensed to the Apache Software Foundation (ASF) under one
# or more contributor license agreements. See the NOTICE file
# distributed with this work for additional information
# regarding copyright ownership. The ASF licenses this file
# to you under the Apache License, Version 2.0 (the
# "License"); you may not use this file except in compliance
# with the License. You may obtain a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
"""LeRobot source resolution for local, Hub, and FileIO datasets."""
from array import array
import json
import logging
import posixpath
from bisect import bisect_right
from contextlib import closing, contextmanager
from dataclasses import dataclass
from pathlib import Path
from typing import Optional
from urllib.parse import quote, unquote, urlparse, urlunparse
import pyarrow as pa
import pyarrow.fs as pafs
import pyarrow.parquet as pq
from pypaimon.common.options import Options
from pypaimon.common.uri_reader import FileUriReader
from pypaimon.filesystem.pyarrow_file_io import LegacyOssDirectoryListingError
from pypaimon.multimodal.lerobot.schema import _video_feature_names
from pypaimon.multimodal.source_utils import (
_SourceFileIO,
_normalize_source_path,
_qualified_status_path,
)
from pypaimon.multimodal.lerobot.loader import _encode_media_frame
_LOGGER = logging.getLogger(__name__)
@dataclass(frozen=True)
class _LeRobotSource:
path: str
root: Optional[Path]
repo_id: str
file_io: object = None
@dataclass(frozen=True)
class _RemoteLeRobotMeta:
info: dict
episodes: object
tasks: list
class _RemoteEpisodeIndex:
def __init__(self, video_fields=()):
self.starts = array("q")
self._ends = array("q")
self._chunk_indices = array("q")
self._file_indices = array("q")
self._video_fields = tuple(video_fields)
self._video_indices = {
field: (array("q"), array("q"))
for field in self._video_fields
}
self._video_timestamps = {
field: (array("d"), array("d"))
for field in self._video_fields
}
def append(
self, begin, end, chunk_index, file_index,
video_values=None):
self.starts.append(begin)
self._ends.append(end)
self._chunk_indices.append(chunk_index)
self._file_indices.append(file_index)
video_values = video_values or {}
for field in self._video_fields:
values = video_values[field]
chunk_indices, file_indices = self._video_indices[field]
from_timestamps, to_timestamps = self._video_timestamps[field]
chunk_indices.append(values[0])
file_indices.append(values[1])
from_timestamps.append(values[2])
to_timestamps.append(values[3])
def __len__(self):
return len(self.starts)
def __getitem__(self, index):
if index < 0:
index += len(self)
if index < 0 or index >= len(self):
raise IndexError(index)
result = {
"episode_index": index,
"dataset_from_index": self.starts[index],
"dataset_to_index": self._ends[index],
"length": self._ends[index] - self.starts[index],
"data/chunk_index": self._chunk_indices[index],
"data/file_index": self._file_indices[index],
}
for field in self._video_fields:
chunk_indices, file_indices = self._video_indices[field]
from_timestamps, to_timestamps = self._video_timestamps[field]
prefix = "videos/%s/" % field
result[prefix + "chunk_index"] = chunk_indices[index]
result[prefix + "file_index"] = file_indices[index]
result[prefix + "from_timestamp"] = from_timestamps[index]
result[prefix + "to_timestamp"] = to_timestamps[index]
return result
@contextmanager
def _resolved_source(source, source_options):
if isinstance(source, Path):
root = source.expanduser().resolve()
if not root.is_dir():
raise FileNotFoundError(
"LeRobot source directory does not exist: %s" % root)
yield _local_source(root)
return
if not isinstance(source, str) or not source.strip():
raise ValueError(
"source must be a local directory or Hugging Face repo_id.")
value = source.strip()
candidate = Path(value).expanduser()
if candidate.is_dir():
yield _local_source(candidate.resolve())
return
if candidate.is_absolute() or value.startswith((".", "~")):
raise FileNotFoundError(
"LeRobot source directory does not exist: %s" % candidate)
if "://" not in value:
yield _LeRobotSource(path=value, root=None, repo_id=value), None
return
source_uri = _normalize_source_path(value).rstrip("/")
source_file_io = _SourceFileIO(Options(source_options))
try:
try:
status = source_file_io.get_file_status(source_uri)
except FileNotFoundError as error:
raise FileNotFoundError(
"LeRobot source directory does not exist: %s" % source_uri
) from error
if status.type != pafs.FileType.Directory:
raise ValueError(
"LeRobot URI source must be a directory: %s" % source_uri)
info = _read_remote_json(
source_file_io, _remote_path(source_uri, "meta/info.json"))
yield (
_LeRobotSource(
path=source_uri,
root=None,
repo_id="",
file_io=source_file_io,
),
info,
)
finally:
_close_quietly(source_file_io, "source FileIO")
def _close_quietly(resource, name):
try:
resource.close()
except Exception:
_LOGGER.warning("Failed to close LeRobot %s.", name, exc_info=True)
def _local_source(root, display_path=None):
info_path = root / "meta" / "info.json"
if not info_path.is_file():
raise ValueError(
"LeRobot source is missing meta/info.json: %s"
% (display_path or root))
try:
with info_path.open("r", encoding="utf-8") as file:
info = json.load(file)
except (OSError, ValueError) as error:
raise ValueError(
"Cannot read LeRobot metadata %s: %s"
% (info_path, error)) from error
return (
_LeRobotSource(
path=str(display_path or root),
root=root,
repo_id="local/pypaimon-import",
),
info,
)
def _import_lerobot_dataset():
try:
from lerobot.datasets.lerobot_dataset import LeRobotDataset
except ImportError as error:
raise ImportError(
"load_from_lerobot requires LeRobot; install "
"'pypaimon[lerobot]'.") from error
return LeRobotDataset
def _info_dict(info):
# LeRobot 0.6 exposes DatasetInfo instead of a dict.
return dict(info.to_dict() if hasattr(info, "to_dict") else info)
def _load_hub_info(source):
try:
from lerobot.datasets.lerobot_dataset import LeRobotDatasetMetadata
except ImportError as error:
raise ImportError(
"load_from_lerobot requires LeRobot; install "
"'pypaimon[lerobot]'.") from error
try:
metadata = LeRobotDatasetMetadata(repo_id=source.repo_id)
# DatasetInfo omits unknown fields; preserve the downloaded source JSON.
return _local_source(Path(metadata.root), source.path)[1]
except Exception as error:
raise ValueError(
"Cannot open LeRobot Dataset v3 metadata %s: %s"
% (source.path, error)) from error
def _open_dataset(LeRobotDataset, source, download_videos=False):
try:
if source.root is not None:
return LeRobotDataset(
repo_id=source.repo_id,
root=source.root,
download_videos=download_videos,
)
return LeRobotDataset(
repo_id=source.repo_id,
download_videos=download_videos,
)
except Exception as error:
raise ValueError(
"Cannot open LeRobot Dataset v3 source %s: %s"
% (source.path, error)) from error
def _open_resolved_dataset(
LeRobotDataset, source, info, download_videos=False):
if source.file_io is not None:
return _RemoteLeRobotDataset(source, info)
return _open_dataset(LeRobotDataset, source, download_videos)
class _RemoteLeRobotDataset:
_EPISODE_COLUMNS = [
"episode_index",
"dataset_from_index",
"dataset_to_index",
"data/chunk_index",
"data/file_index",
]
def __init__(self, source, info):
self.source = source
self.root = source.path
self._file_io = source.file_io
self._episodes = self._load_episodes(info)
self._tasks = self._load_tasks(info)
self.meta = _RemoteLeRobotMeta(info, self._episodes, self._tasks)
self._episode_starts = self._episodes.starts
self._data_ranges = self._build_data_ranges(info)
self._cached_data_path = None
self._cached_data_table = None
def __len__(self):
return int(self.meta.info.get("total_frames", 0))
def close(self):
self._cached_data_table = None
def read_batch(self, begin, end):
episode = self._episode_for_range(begin, end)
relative_path = self._data_path(episode, self.meta.info)
source_path = _remote_source_path(
self.source.path,
relative_path,
"info.data_path",
self._file_io,
)
if source_path != self._cached_data_path:
table = _read_remote_parquet(self._file_io, source_path)
expected_begin, expected_end = self._data_ranges[relative_path]
expected_rows = expected_end - expected_begin
if table.num_rows != expected_rows:
raise ValueError(
"LeRobot data file %s has %d rows; metadata expects %d."
% (source_path, table.num_rows, expected_rows))
self._cached_data_path = source_path
self._cached_data_table = table
file_begin = self._data_ranges[relative_path][0]
return self._cached_data_table.slice(begin - file_begin, end - begin)
def image_bytes(self, value):
if value is None:
raise ValueError("LeRobot image feature contains a null frame.")
if isinstance(value, (bytes, bytearray, memoryview)):
return bytes(value)
if isinstance(value, dict):
body = value.get("bytes")
if body is not None:
return bytes(body)
image_path = value.get("path")
if image_path:
source_path = _remote_source_path(
self.source.path,
image_path,
"image path",
self._file_io,
)
return _read_remote_bytes(self._file_io, source_path)
return _encode_media_frame(value)
def video_source(self, video_key, episode):
relative_path = self.meta.info["video_path"].format(
video_key=video_key,
chunk_index=int(episode[
"videos/%s/chunk_index" % video_key]),
file_index=int(episode[
"videos/%s/file_index" % video_key]),
)
relative_path = _relative_dataset_path(
relative_path, "info.video_path")
path = _remote_source_path(
self.source.path,
relative_path,
"info.video_path",
self._file_io,
)
status = self._file_io.get_file_status(path)
if status.type != pafs.FileType.File \
or status.size is None or status.size <= 0:
raise ValueError("LeRobot video file is empty: %s" % path)
return path, int(status.size)
@property
def video_uri_reader_factory(self):
return _SourceUriReaderFactory(self._file_io)
def _load_episodes(self, info):
episode_count = int(info.get("total_episodes", 0))
if episode_count == 0:
return []
directory = _remote_path(self.source.path, "meta/episodes")
paths = _remote_parquet_files(self._file_io, directory)
video_fields = _video_feature_names(info)
selected_columns = list(self._EPISODE_COLUMNS)
for field in video_fields:
selected_columns.extend([
"videos/%s/%s" % (field, suffix)
for suffix in (
"chunk_index",
"file_index",
"from_timestamp",
"to_timestamp",
)
])
episodes = _RemoteEpisodeIndex(video_fields)
for path in paths:
table = _read_remote_parquet(
self._file_io,
path,
columns=selected_columns,
)
control_columns = {
name: table.column(name)
for name in self._EPISODE_COLUMNS
}
for offset in range(table.num_rows):
episode_index = int(
control_columns["episode_index"][offset].as_py())
if episode_index != len(episodes):
raise ValueError(
"LeRobot Episode metadata must be ordered by "
"episode_index.")
episodes.append(
int(control_columns[
"dataset_from_index"][offset].as_py()),
int(control_columns[
"dataset_to_index"][offset].as_py()),
int(control_columns[
"data/chunk_index"][offset].as_py()),
int(control_columns[
"data/file_index"][offset].as_py()),
{
field: (
int(table.column(
"videos/%s/chunk_index" % field
)[offset].as_py()),
int(table.column(
"videos/%s/file_index" % field
)[offset].as_py()),
float(table.column(
"videos/%s/from_timestamp" % field
)[offset].as_py()),
float(table.column(
"videos/%s/to_timestamp" % field
)[offset].as_py()),
)
for field in video_fields
},
)
if len(episodes) != episode_count:
raise ValueError(
"LeRobot metadata reports %d Episodes but %d were found."
% (episode_count, len(episodes)))
return episodes
def _load_tasks(self, info):
task_count = int(info.get("total_tasks", 0))
if task_count == 0:
return []
path = _remote_path(self.source.path, "meta/tasks.parquet")
table = _read_remote_parquet(self._file_io, path)
name_column = _pandas_index_column(table.schema, "task")
rows = table.select(["task_index", name_column]).to_pylist()
tasks = [None] * task_count
for row in rows:
index = int(row["task_index"])
name = row[name_column]
if index < 0 or index >= task_count \
or not isinstance(name, str) or not name:
raise ValueError("LeRobot task metadata is invalid: %s" % row)
tasks[index] = name
if any(task is None for task in tasks):
raise ValueError(
"LeRobot metadata reports %d tasks but %d were found."
% (task_count, len(rows)))
return tasks
def _build_data_ranges(self, info):
ranges = {}
for episode in self._episodes:
path = self._data_path(episode, info)
begin = int(episode["dataset_from_index"])
end = int(episode["dataset_to_index"])
if path in ranges:
previous_begin, previous_end = ranges[path]
if begin != previous_end:
raise ValueError(
"LeRobot data file %s has non-contiguous Episode "
"ranges." % path)
ranges[path] = previous_begin, end
else:
ranges[path] = begin, end
return ranges
def _episode_for_range(self, begin, end):
index = bisect_right(self._episode_starts, begin) - 1
if index < 0:
raise ValueError("LeRobot frame range starts before Episode 0.")
episode = self._episodes[index]
episode_end = int(episode["dataset_to_index"])
if end > episode_end:
raise ValueError("LeRobot frame batch crosses an Episode boundary.")
return episode
@staticmethod
def _data_path(episode, info):
path = info["data_path"].format(
chunk_index=int(episode["data/chunk_index"]),
file_index=int(episode["data/file_index"]),
)
return _relative_dataset_path(path, "info.data_path")
def _remote_path(root, relative_path):
return "%s/%s" % (root.rstrip("/"), relative_path.lstrip("/"))
class _SourceUriReaderFactory:
def __init__(self, file_io):
self._reader = FileUriReader(file_io)
def create(self, unused_uri):
return self._reader
def _pandas_index_column(schema, component):
encoded = (schema.metadata or {}).get(b"pandas")
if encoded is None:
# Arrow-written task tables may omit Pandas index metadata.
expected = {component + "_index", component}
if len(schema.names) == 2 and set(schema.names) == expected \
and (pa.types.is_string(schema.field(component).type)
or pa.types.is_large_string(schema.field(component).type)):
return component
raise ValueError(
"LeRobot %s metadata must contain a Pandas index or the "
"canonical %s and %s columns."
% (component, component + "_index", component))
try:
pandas_metadata = json.loads(encoded.decode("utf-8"))
index_columns = pandas_metadata["index_columns"]
except (AttributeError, KeyError, TypeError, ValueError,
UnicodeDecodeError) as error:
raise ValueError(
"LeRobot %s metadata must contain a Pandas index."
% component
) from error
if not isinstance(index_columns, list) \
or len(index_columns) != 1 \
or not isinstance(index_columns[0], str) \
or index_columns[0] not in schema.names \
or index_columns[0] == component + "_index":
raise ValueError(
"LeRobot %s metadata must contain one text Pandas index."
% component)
return index_columns[0]
def _relative_dataset_path(path, name):
if not isinstance(path, str) or not path:
raise ValueError("LeRobot %s must be a relative path." % name)
path = _fully_unquote(path)
if "\\" in path or urlparse(path).scheme or path.startswith(("/", "~")):
raise ValueError(
"LeRobot %s must stay within the source directory: %s"
% (name, path))
normalized = posixpath.normpath(path)
if normalized in (".", "..") or normalized.startswith("../"):
raise ValueError(
"LeRobot %s must stay within the source directory: %s"
% (name, path))
return normalized
def _remote_source_path(root, path, name, source_file_io=None):
if not isinstance(path, str) or not path:
_relative_dataset_path(path, name)
root_uri = urlparse(root)
path_uri = urlparse(path)
root_path = posixpath.normpath(_fully_unquote(root_uri.path) or "/")
if path_uri.scheme:
if path_uri.query or path_uri.fragment \
or path_uri.scheme.lower() != root_uri.scheme.lower() \
or path_uri.netloc != root_uri.netloc:
raise ValueError(
"LeRobot %s must stay within the source directory: %s"
% (name, path))
source_path = posixpath.normpath(
_fully_unquote(path_uri.path) or "/")
elif root_uri.scheme.lower() == "file" and Path(path).is_absolute():
source_path = str(Path(path).resolve())
else:
relative_path = _relative_dataset_path(path, name)
source_path = posixpath.normpath(posixpath.join(
root_path,
relative_path,
))
if posixpath.commonpath([root_path, source_path]) != root_path:
raise ValueError(
"LeRobot %s must stay within the source directory: %s"
% (name, path))
source_uri = urlunparse((
root_uri.scheme,
root_uri.netloc,
quote(source_path, safe="/:"),
"",
"",
"",
))
_validate_filesystem_containment(
source_file_io, root, source_uri, name, path)
return source_uri
def _fully_unquote(path):
decoded = unquote(path)
while decoded != path:
path = decoded
decoded = unquote(path)
return decoded
def _validate_filesystem_containment(
source_file_io, root, source, name, original_path):
to_filesystem_path = getattr(
source_file_io, "to_filesystem_path", None)
if not callable(to_filesystem_path):
return
root_path = _fully_unquote(to_filesystem_path(root))
source_path = _fully_unquote(to_filesystem_path(source))
if urlparse(root).scheme.lower() == "file":
root_path = Path(root_path).resolve()
source_path = Path(source_path).resolve()
try:
source_path.relative_to(root_path)
except ValueError as error:
raise ValueError(
"LeRobot %s must stay within the source directory: %s"
% (name, original_path)) from error
return
else:
root_path = posixpath.normpath(root_path)
source_path = posixpath.normpath(source_path)
relative_path = posixpath.relpath(source_path, root_path)
if relative_path == ".." or relative_path.startswith("../"):
raise ValueError(
"LeRobot %s must stay within the source directory: %s"
% (name, original_path))
def _validate_info_paths(info):
for name in ("data_path", "video_path"):
path = info.get(name)
if path is not None:
_relative_dataset_path(path, "info.%s" % name)
def _read_remote_bytes(source_file_io, path):
stream = source_file_io.new_input_stream(path)
with closing(stream) as source_stream:
return source_stream.read()
def _read_remote_json(source_file_io, path):
try:
return json.loads(_read_remote_bytes(
source_file_io, path).decode("utf-8"))
except (OSError, UnicodeError, ValueError) as error:
raise ValueError(
"Cannot read LeRobot metadata %s: %s" % (path, error)) from error
def _read_remote_parquet(source_file_io, path, columns=None):
stream = source_file_io.new_input_stream(path)
with closing(stream) as source_stream:
try:
return pq.read_table(source_stream, columns=columns)
except (OSError, ValueError, pa.ArrowException) as error:
raise ValueError(
"Cannot read LeRobot Parquet file %s: %s"
% (path, error)) from error
def _read_remote_parquet_schema(source_file_io, path):
stream = source_file_io.new_input_stream(path)
with closing(stream) as source_stream:
try:
return pq.read_schema(source_stream)
except (OSError, ValueError, pa.ArrowException) as error:
raise ValueError(
"Cannot read LeRobot Parquet schema %s: %s"
% (path, error)) from error
def _remote_parquet_files(source_file_io, directory):
try:
statuses = source_file_io.list_status(directory)
except LegacyOssDirectoryListingError as error:
raise ValueError(
"LeRobot URI directory listing is unavailable at %s; use "
"Jindo or upgrade PyArrow." % directory) from error
paths = []
for status in statuses:
path = _qualified_status_path(directory, status)
if status.type == pafs.FileType.Directory:
paths.extend(_remote_parquet_files(source_file_io, path))
elif status.type == pafs.FileType.File and path.endswith(".parquet"):
paths.append(path)
return sorted(paths)