Skip to main content

re_lerobot/
lib.rs

1#![allow(clippy::iter_over_hash_type)]
2
3//! A crate for loading and working with `LeRobot` datasets.
4//!
5//! This module provides functionality to identify and parse `LeRobot` datasets,
6//! which consist of metadata and episode data stored in a structured format.
7//!
8//! # Important
9//!
10//! This module supports v2 and v3 `LeRobot` datasets!
11//!
12//! See [`datasetv2::LeRobotDatasetV2`] and [`datasetv3::LeRobotDatasetV3`] for more information on the dataset formats.
13pub mod common;
14pub mod datasetv2;
15pub mod datasetv3;
16
17use std::{fmt, path::Path};
18
19use serde::{
20    Deserialize, Deserializer, Serialize,
21    de::{MapAccess, SeqAccess, Visitor},
22};
23
24#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
25pub enum LeRobotDatasetVersion {
26    V1,
27    V2,
28    V3,
29}
30
31impl LeRobotDatasetVersion {
32    pub fn find_version(path: impl AsRef<Path>) -> Option<Self> {
33        let path = path.as_ref();
34
35        if is_v3_lerobot_dataset(path) {
36            Some(Self::V3)
37        } else if is_v2_lerobot_dataset(path) {
38            Some(Self::V2)
39        } else if is_v1_lerobot_dataset(path) {
40            Some(Self::V1)
41        } else {
42            None
43        }
44    }
45}
46
47/// Check whether the provided path contains a `LeRobot` dataset.
48pub fn is_lerobot_dataset(path: impl AsRef<Path>) -> bool {
49    is_v1_lerobot_dataset(path.as_ref())
50        || is_v2_lerobot_dataset(path.as_ref())
51        || is_v3_lerobot_dataset(path.as_ref())
52}
53
54/// Check whether the provided path contains a v3 `LeRobot` dataset.
55fn is_v3_lerobot_dataset(_path: impl AsRef<Path>) -> bool {
56    let path = _path.as_ref();
57
58    if !path.is_dir() {
59        return false;
60    }
61
62    // v3 `LeRobot` datasets have per episode metadata stored under `meta/episodes/`
63    has_sub_directories(&["meta", "data"], path) && path.join("meta").join("episodes").is_dir()
64}
65
66/// Check whether the provided path contains a v2 `LeRobot` dataset.
67fn is_v2_lerobot_dataset(path: impl AsRef<Path>) -> bool {
68    let path = path.as_ref();
69
70    if !path.is_dir() {
71        return false;
72    }
73
74    // v2 `LeRobot` datasets store the metadata in a `meta` directory,
75    // instead of the `meta_data` directory used in v1 datasets.
76    has_sub_directories(&["meta", "data"], path)
77}
78
79/// Check whether the provided path contains a v1 `LeRobot` dataset.
80fn is_v1_lerobot_dataset(path: impl AsRef<Path>) -> bool {
81    let path = path.as_ref();
82
83    if !path.is_dir() {
84        return false;
85    }
86
87    // v1 `LeRobot` datasets stored the metadata in a `meta_data` directory,
88    // instead of the `meta` directory used in v2 datasets.
89    has_sub_directories(&["meta_data", "data"], path)
90}
91
92fn has_sub_directories(directories: &[&str], path: impl AsRef<Path>) -> bool {
93    directories.iter().all(|subdir| {
94        let subpath = path.as_ref().join(subdir);
95
96        // check that the sub directory exists and is not empty
97        subpath.is_dir()
98            && subpath
99                .read_dir()
100                .is_ok_and(|mut contents| contents.next().is_some())
101    })
102}
103
104/// Feature definition for a `LeRobot` dataset.
105///
106/// Each feature represents a data stream recorded during an episode, of a specific data type (`dtype`)
107/// and dimensionality (`shape`).
108///
109/// For example, a shape of `[3, 224, 224]` for a [`DType::Image`] feature denotes a 3-channel (e.g. RGB)
110/// image with a height and width of 224 pixels each.
111#[derive(Serialize, Deserialize, Debug, Clone)]
112pub struct Feature {
113    pub dtype: DType,
114    pub shape: Vec<usize>,
115    pub names: Option<Names>,
116}
117
118impl Feature {
119    /// Get the channel dimension for this [`Feature`].
120    ///
121    /// Returns the number of channels in the feature's data representation.
122    ///
123    /// # Note
124    ///
125    /// This is primarily intended for [`DType::Image`] and [`DType::Video`] features,
126    /// where it represents color channels (e.g., 3 for RGB, 4 for RGBA).
127    /// For other feature types, this function returns the size of the last dimension
128    /// from the feature's shape.
129    pub fn channel_dim(&self) -> usize {
130        // first check if there's a "channels" name, if there is we can use that index.
131        if let Some(names) = &self.names
132            && let Some(channel_idx) = names.0.iter().position(|name| name == "channels")
133        {
134            // If channel_idx is within bounds of shape, return that dimension
135            if channel_idx < self.shape.len() {
136                return self.shape[channel_idx];
137            }
138        }
139
140        // Default to the last dimension if no channels name is found
141        // or if the found index is out of bounds
142        self.shape.last().copied().unwrap_or(0)
143    }
144}
145
146/// Data types supported for features in a `LeRobot` dataset.
147#[derive(Serialize, Deserialize, Debug, Clone, Copy, PartialEq, Eq)]
148#[serde(rename_all = "snake_case")]
149pub enum DType {
150    Video,
151    Image,
152    Bool,
153    Float32,
154    Float64,
155    Int16,
156    Int64,
157    String,
158    Language,
159}
160
161/// Name metadata for a feature in the `LeRobot` dataset.
162///
163/// The name metadata can consist of
164/// - A single string (e.g., `"img_state_delta"`).
165/// - A flat list of names for each dimension of a feature (e.g., `["height", "width", "channel"]`).
166/// - A nested list of names for each dimension of a feature (e.g., `[["kLeftShoulderPitch", "kLeftShoulderRoll"]]`)
167/// - A map with a string array value (e.g., `{ "motors": ["motor_0", "motor_1", …] }` or `{ "axes": ["x", "y", "z"] }`).
168#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
169pub struct Names(pub(crate) Vec<String>);
170
171impl Names {
172    /// Retrieves the name corresponding to a specific index.
173    ///
174    /// Returns `None` if the index is out of bounds.
175    pub fn name_for_index(&self, index: usize) -> Option<&String> {
176        self.0.get(index)
177    }
178}
179
180/// Visitor implementation for deserializing the [`Names`] type.
181///
182/// Handles multiple representation formats:
183/// - Single strings: `"img_state_delta"`
184/// - Flat string arrays: `["x", "y", "z"]`
185/// - Nested string arrays: `[["motor_1", "motor_2"]]`
186/// - Single-entry objects: `{"motors": ["motor_1", "motor_2"]}` or `{"axes": null}`
187///
188/// See the `Names` type documentation for more details on the supported formats.
189struct NamesVisitor;
190
191impl<'de> Visitor<'de> for NamesVisitor {
192    type Value = Names;
193
194    fn expecting(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
195        formatter.write_str(
196            "a string, a flat string array, a nested string array, or a single-entry object with a string array or null value",
197        )
198    }
199
200    /// Handle a single string: `"img_state_delta"`
201    fn visit_str<E>(self, v: &str) -> Result<Self::Value, E>
202    where
203        E: serde::de::Error,
204    {
205        Ok(Names(vec![v.to_owned()]))
206    }
207
208    /// Handle sequences:
209    /// - Flat string arrays: `["x", "y", "z"]`
210    /// - Nested string arrays: `[["motor_1", "motor_2"]]`
211    fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
212    where
213        A: SeqAccess<'de>,
214    {
215        // Helper enum to deserialize sequence elements
216        #[derive(Deserialize)]
217        #[serde(untagged)]
218        enum ListItem {
219            Str(String),
220            List(Vec<String>),
221        }
222
223        /// Enum to track the list type
224        #[derive(PartialEq)]
225        enum ListType {
226            Undetermined,
227            Flat,
228            Nested,
229        }
230
231        let mut names = Vec::new();
232        let mut determined_type = ListType::Undetermined;
233
234        while let Some(item) = seq.next_element::<ListItem>()? {
235            match item {
236                ListItem::Str(s) => {
237                    if determined_type == ListType::Nested {
238                        return Err(serde::de::Error::custom(
239                            "Cannot mix nested lists with flat strings within names array",
240                        ));
241                    }
242                    determined_type = ListType::Flat;
243                    names.push(s);
244                }
245                ListItem::List(list) => {
246                    if determined_type == ListType::Flat {
247                        return Err(serde::de::Error::custom(
248                            "Cannot mix flat strings and nested lists within names array",
249                        ));
250                    }
251                    determined_type = ListType::Nested;
252
253                    // Flatten the nested list
254                    names.extend(list);
255                }
256            }
257        }
258
259        Ok(Names(names))
260    }
261
262    /// Handle single-entry objects: `{"motors": ["motor_1", "motor_2"]}` or `{"axes": null}`
263    fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
264    where
265        A: MapAccess<'de>,
266    {
267        let mut names_vec: Option<Vec<String>> = None;
268        let mut entry_count = 0;
269
270        // We expect exactly one entry.
271        while let Some((_key, value)) = map.next_entry::<String, Option<Vec<String>>>()? {
272            entry_count += 1;
273            if entry_count > 1 {
274                // Consume remaining entries to be a good citizen before erroring
275                while map
276                    .next_entry::<serde::de::IgnoredAny, serde::de::IgnoredAny>()?
277                    .is_some()
278                {}
279
280                return Err(serde::de::Error::invalid_length(
281                    entry_count,
282                    &"a Names object with exactly one entry.",
283                ));
284            }
285
286            names_vec = Some(value.unwrap_or_default());
287        }
288
289        Ok(Names(names_vec.unwrap_or_default()))
290    }
291}
292
293impl<'de> Deserialize<'de> for Names {
294    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
295    where
296        D: Deserializer<'de>,
297    {
298        deserializer.deserialize_any(NamesVisitor)
299    }
300}
301
302/// Newtype wrapper for episode indices.
303#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
304#[serde(transparent)]
305pub struct EpisodeIndex(pub usize);
306
307/// Newtype wrapper for task indices.
308#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
309#[serde(transparent)]
310pub struct TaskIndex(pub usize);
311
312/// Newtype wrapper for subtask indices.
313#[derive(Debug, Serialize, Deserialize, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)]
314#[serde(transparent)]
315pub struct SubtaskIndex(pub usize);
316
317/// A task in a `LeRobot` dataset.
318///
319/// Each task consists of its index and a task description.
320#[derive(Debug, Serialize, Deserialize, Clone)]
321pub struct LeRobotDatasetTask {
322    #[serde(rename = "task_index")]
323    pub index: TaskIndex,
324    pub task: String,
325}
326
327/// A subtask in a `LeRobot` dataset.
328///
329/// Subtasks break down complex tasks into finer-grained, interpretable steps.
330/// Each subtask consists of its index and a subtask description.
331#[derive(Debug, Serialize, Deserialize, Clone)]
332pub struct LeRobotDatasetSubtask {
333    #[serde(rename = "subtask_index")]
334    pub index: SubtaskIndex,
335    pub subtask: String,
336}
337
338/// Errors that might happen when loading data from a `LeRobot` dataset.
339#[derive(thiserror::Error, Debug)]
340pub enum LeRobotError {
341    #[error("IO error occurred on path: {path}")]
342    IO {
343        #[source]
344        source: std::io::Error,
345        path: std::path::PathBuf,
346    },
347
348    #[error(transparent)]
349    Json(#[from] serde_json::Error),
350
351    #[error(transparent)]
352    Parquet(#[from] parquet::errors::ParquetError),
353
354    #[error(transparent)]
355    Arrow(#[from] arrow::error::ArrowError),
356
357    #[error("Invalid feature key: {0}")]
358    InvalidFeatureKey(String),
359
360    #[error("Missing dataset info: {0}")]
361    MissingDatasetInfo(String),
362
363    #[error("Invalid feature dtype, expected {key} to be of type {expected:?}, but got {actual:?}")]
364    InvalidFeatureDtype {
365        key: String,
366        expected: DType,
367        actual: DType,
368    },
369
370    #[error("Invalid chunk index: {0}")]
371    InvalidChunkIndex(usize),
372
373    #[error("Invalid episode index: {0:?}")]
374    InvalidEpisodeIndex(EpisodeIndex),
375
376    #[error("Episode {0:?} data file does not contain any records")]
377    EmptyEpisode(EpisodeIndex),
378
379    #[error(transparent)]
380    Chunk(#[from] re_chunk::ChunkError),
381
382    #[error("{}", re_error::format(.0))]
383    Other(#[from] anyhow::Error),
384}
385
386impl LeRobotError {
387    /// Create an IO error with the given source and path.
388    pub fn io(source: std::io::Error, path: impl Into<std::path::PathBuf>) -> Self {
389        Self::IO {
390            source,
391            path: path.into(),
392        }
393    }
394}
395
396#[cfg(test)]
397mod tests {
398    use super::*;
399
400    #[test]
401    fn test_deserialize_single_string() {
402        let json = r#""some_name""#;
403        let expected = Names(vec!["some_name".to_owned()]);
404        let names: Names = serde_json::from_str(json).unwrap();
405        assert_eq!(names, expected);
406    }
407
408    #[test]
409    fn test_deserialize_flat_list() {
410        let json = r#"["a", "b", "c"]"#;
411        let expected = Names(vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
412        let names: Names = serde_json::from_str(json).unwrap();
413        assert_eq!(names, expected);
414    }
415
416    #[test]
417    fn test_deserialize_nested_list() {
418        let json = r#"[["a", "b"], ["c"]]"#;
419        let expected = Names(vec!["a".to_owned(), "b".to_owned(), "c".to_owned()]);
420        let names: Names = serde_json::from_str(json).unwrap();
421        assert_eq!(names, expected);
422    }
423
424    #[test]
425    fn test_deserialize_empty_nested_list() {
426        let json = r#"[[], []]"#;
427        let expected = Names(vec![]);
428        let names: Names = serde_json::from_str(json).unwrap();
429        assert_eq!(names, expected);
430    }
431
432    #[test]
433    fn test_deserialize_empty_list() {
434        let json = r#"[]"#;
435        let expected = Names(vec![]);
436        let names: Names = serde_json::from_str(json).unwrap();
437        assert_eq!(names, expected);
438    }
439
440    #[test]
441    fn test_deserialize_object_with_list() {
442        let json = r#"{ "axes": ["x", "y", "z"] }"#;
443        let expected = Names(vec!["x".to_owned(), "y".to_owned(), "z".to_owned()]);
444        let names: Names = serde_json::from_str(json).unwrap();
445        assert_eq!(names, expected);
446    }
447
448    #[test]
449    fn test_deserialize_object_with_empty_list() {
450        let json = r#"{ "motors": [] }"#;
451        let expected = Names(vec![]);
452        let names: Names = serde_json::from_str(json).unwrap();
453        assert_eq!(names, expected);
454    }
455
456    #[test]
457    fn test_deserialize_object_with_null() {
458        let json = r#"{ "axes": null }"#;
459        let expected = Names(vec![]); // Null results in an empty list
460        let names: Names = serde_json::from_str(json).unwrap();
461        assert_eq!(names, expected);
462    }
463
464    #[test]
465    fn test_deserialize_empty_object() {
466        // Empty object results in empty list.
467        let json = r#"{}"#;
468        let expected = Names(vec![]);
469        let names: Names = serde_json::from_str(json).unwrap();
470        assert_eq!(names, expected);
471    }
472
473    #[test]
474    fn test_deserialize_error_mixed_list() {
475        let json = r#"["a", ["b"]]"#; // Mixed flat and nested
476        let result: Result<Names, _> = serde_json::from_str(json);
477        assert!(result.is_err());
478        assert!(
479            result
480                .unwrap_err()
481                .to_string()
482                .contains("Cannot mix flat strings and nested lists")
483        );
484    }
485
486    #[test]
487    fn test_deserialize_feature_with_language_dtype() {
488        // Regression: `"language"` features must deserialize rather than
489        // aborting the whole import (previously an unknown-variant error).
490        let json = r#"{ "dtype": "language", "shape": [1], "names": null }"#;
491        let feature: Feature = serde_json::from_str(json).unwrap();
492        assert_eq!(feature.dtype, DType::Language);
493    }
494
495    #[test]
496    fn test_deserialize_error_object_multiple_entries() {
497        let json = r#"{ "axes": ["x"], "motors": ["m"] }"#;
498        let result: Result<Names, _> = serde_json::from_str(json);
499        assert!(result.is_err());
500        assert!(
501            result
502                .unwrap_err()
503                .to_string()
504                .contains("a Names object with exactly one entry")
505        );
506    }
507}