Skip to content

Commit 8f244dd

Browse files
authored
Fix LeRobot tasks.parquet compatibility and v3 streaming video staging (#1)
* Support LeRobot-compatible tasks.parquet and v3 streaming video staging. Write task descriptions as pandas index metadata and extend staging/merge paths needed by the updated mcap2lerobot converter. * Fix CI lint and test skips for optional pandas/ffmpeg dependencies. Remove unreachable staging branches, gofmt affected files, and skip environment-dependent tests when pandas or ffmpeg are unavailable. --------- Co-authored-by: joaner <joaner@users.noreply.github.com>
1 parent 84ee6e9 commit 8f244dd

32 files changed

Lines changed: 2211 additions & 174 deletions

internal/buffer/episode.go

Lines changed: 23 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -72,6 +72,25 @@ func (b *EpisodeBuffer) Columns(globalIndex int64, taskIndices []int64) map[stri
7272
return out
7373
}
7474

75+
func (b *EpisodeBuffer) ColumnsWithFrameStart(globalIndex, frameStart int64, taskIndices []int64) map[string]any {
76+
out := b.Columns(globalIndex, taskIndices)
77+
frameIdx := make([]int64, b.size)
78+
timestamps := make([]float32, b.size)
79+
for i := range frameIdx {
80+
frameIdx[i] = frameStart + int64(i)
81+
timestamps[i] = float32(frameStart+int64(i)) / float32(b.FPS)
82+
}
83+
out["frame_index"] = frameIdx
84+
out["timestamp"] = timestamps
85+
return out
86+
}
87+
88+
func (b *EpisodeBuffer) Reset() {
89+
b.columns = make(map[string]any)
90+
b.tasks = nil
91+
b.size = 0
92+
}
93+
7594
func isScalarShape(shape []int) bool {
7695
return len(shape) == 0 || (len(shape) == 1 && shape[0] == 1)
7796
}
@@ -143,21 +162,21 @@ func appendFloat32Row(b *EpisodeBuffer, key string, row []float32) {
143162
if _, ok := b.columns[key]; !ok {
144163
b.columns[key] = [][]float32{}
145164
}
146-
b.columns[key] = append(b.columns[key].([][]float32), row)
165+
b.columns[key] = append(b.columns[key].([][]float32), append([]float32(nil), row...))
147166
}
148167

149168
func appendFloat64Row(b *EpisodeBuffer, key string, row []float64) {
150169
if _, ok := b.columns[key]; !ok {
151170
b.columns[key] = [][]float64{}
152171
}
153-
b.columns[key] = append(b.columns[key].([][]float64), row)
172+
b.columns[key] = append(b.columns[key].([][]float64), append([]float64(nil), row...))
154173
}
155174

156175
func appendInt64Row(b *EpisodeBuffer, key string, row []int64) {
157176
if _, ok := b.columns[key]; !ok {
158177
b.columns[key] = [][]int64{}
159178
}
160-
b.columns[key] = append(b.columns[key].([][]int64), row)
179+
b.columns[key] = append(b.columns[key].([][]int64), append([]int64(nil), row...))
161180
}
162181

163182
func appendFloat64(b *EpisodeBuffer, key string, val float64) {
@@ -170,7 +189,7 @@ func appendFloat64(b *EpisodeBuffer, key string, val float64) {
170189
func toFloat32Row(val any) ([]float32, bool) {
171190
switch v := val.(type) {
172191
case []float32:
173-
return v, true
192+
return append([]float32(nil), v...), true
174193
case []float64:
175194
out := make([]float32, len(v))
176195
for i, x := range v {

internal/buffer/episode_test.go

Lines changed: 76 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,76 @@
1+
package buffer
2+
3+
import (
4+
"math"
5+
"testing"
6+
7+
"github.com/ioai-tech/lerobot-go/internal/meta"
8+
)
9+
10+
func TestAppendFloat64RowDeepCopy(t *testing.T) {
11+
features := map[string]meta.FeatureSpec{
12+
"observation.state": {DType: "float64", Shape: []int{3}},
13+
}
14+
b := New(0, 30, features)
15+
shared := []float64{1, 2, 3}
16+
for i := 0; i < 3; i++ {
17+
if err := b.AddFrame(map[string]any{
18+
"task": "pick",
19+
"observation.state": shared,
20+
}); err != nil {
21+
t.Fatal(err)
22+
}
23+
for j := range shared {
24+
shared[j] = float64(i*10 + j + 100)
25+
}
26+
}
27+
rows := b.columns["observation.state"].([][]float64)
28+
if len(rows) != 3 {
29+
t.Fatalf("rows=%d want 3", len(rows))
30+
}
31+
want := [][]float64{
32+
{1, 2, 3},
33+
{100, 101, 102},
34+
{110, 111, 112},
35+
}
36+
for i, row := range rows {
37+
for j, v := range row {
38+
if v != want[i][j] {
39+
t.Fatalf("row[%d][%d]=%v want %v (slice alias corrupted stored rows)", i, j, v, want[i][j])
40+
}
41+
}
42+
}
43+
}
44+
45+
func TestObservationStateValuesStayFiniteThroughParquetPath(t *testing.T) {
46+
features := map[string]meta.FeatureSpec{
47+
"observation.state": {DType: "float64", Shape: []int{4}},
48+
"action": {DType: "float64", Shape: []int{4}},
49+
}
50+
b := New(0, 30, features)
51+
reuse := make([]float64, 4)
52+
for i := 0; i < 20; i++ {
53+
for j := range reuse {
54+
reuse[j] = float64(i)*0.1 + float64(j)*0.01
55+
}
56+
if err := b.AddFrame(map[string]any{
57+
"task": "move",
58+
"observation.state": reuse,
59+
"action": reuse,
60+
}); err != nil {
61+
t.Fatal(err)
62+
}
63+
}
64+
stateRows := b.columns["observation.state"].([][]float64)
65+
for i, row := range stateRows {
66+
for j, v := range row {
67+
if math.IsNaN(v) || math.IsInf(v, 0) || math.Abs(v) > 100 {
68+
t.Fatalf("state[%d][%d]=%v out of sane joint range", i, j, v)
69+
}
70+
want := float64(i)*0.1 + float64(j)*0.01
71+
if v != want {
72+
t.Fatalf("state[%d][%d]=%v want %v", i, j, v, want)
73+
}
74+
}
75+
}
76+
}

internal/manifest/episode.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ type Episode struct {
2424
const FileName = "episode_meta.json"
2525

2626
func Write(dir string, ep Episode) error {
27+
ep.Stats = stats.SanitizeEpisodeStats(ep.Stats)
2728
data, err := json.MarshalIndent(ep, "", " ")
2829
if err != nil {
2930
return err
@@ -51,6 +52,20 @@ func stagingName(episodeIndex int) string {
5152
return fmt.Sprintf("ep_%06d", episodeIndex)
5253
}
5354

55+
// StagingMediaPath resolves a path stored in episode metadata relative to a staging dir.
56+
// Legacy manifests may store absolute paths; those are returned unchanged.
57+
func StagingMediaPath(stagingDir, rel string) string {
58+
if filepath.IsAbs(rel) {
59+
return rel
60+
}
61+
return filepath.Join(stagingDir, rel)
62+
}
63+
64+
// StagingVideoRel returns the episode-relative path for a staged per-episode MP4.
65+
func StagingVideoRel(videoKey string) string {
66+
return filepath.Join("videos", filepath.Base(videoKey)+".mp4")
67+
}
68+
5469
// ListStagingEpisodes returns completed staging episode dirs sorted by episode_index.
5570
func ListStagingEpisodes(root string) ([]string, error) {
5671
entries, err := os.ReadDir(root)

internal/manifest/episode_test.go

Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
package manifest
2+
3+
import (
4+
"path/filepath"
5+
"testing"
6+
)
7+
8+
func TestStagingMediaPath(t *testing.T) {
9+
dir := "/tmp/ep_000000"
10+
rel := StagingVideoRel("observation.images.cam")
11+
if rel != filepath.Join("videos", "observation.images.cam.mp4") {
12+
t.Fatalf("rel=%q", rel)
13+
}
14+
got := StagingMediaPath(dir, rel)
15+
want := filepath.Join(dir, rel)
16+
if got != want {
17+
t.Fatalf("got %q want %q", got, want)
18+
}
19+
abs := "/tmp/ep_000000/videos/foo.mp4"
20+
if StagingMediaPath(dir, abs) != abs {
21+
t.Fatalf("absolute path should pass through")
22+
}
23+
}

internal/parquetx/append_data.go

Lines changed: 22 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -51,25 +51,33 @@ func WriteEpisodeBatch(ctx context.Context, dst string, entries []EpisodeBatchEn
5151
return fmt.Errorf("no episode parquet entries")
5252
}
5353
alloc := memory.NewGoAllocator()
54-
tables := make([]arrow.Table, 0, len(entries))
55-
defer func() {
56-
for _, tbl := range tables {
57-
tbl.Release()
58-
}
59-
}()
60-
for _, entry := range entries {
54+
first, err := RewriteEpisodeParquet(ctx, entries[0].SourcePath, entries[0].Options, alloc)
55+
if err != nil {
56+
return err
57+
}
58+
defer first.Release()
59+
writer, err := NewAppendWriter(dst, first.Schema())
60+
if err != nil {
61+
return err
62+
}
63+
if err := writer.WriteTable(first, 1024); err != nil {
64+
_ = writer.Close()
65+
return err
66+
}
67+
for _, entry := range entries[1:] {
6168
tbl, err := RewriteEpisodeParquet(ctx, entry.SourcePath, entry.Options, alloc)
6269
if err != nil {
70+
_ = writer.Close()
6371
return err
6472
}
65-
tables = append(tables, tbl)
66-
}
67-
merged, err := ConcatTables(alloc, tables)
68-
if err != nil {
69-
return err
73+
if err := writer.WriteTable(tbl, 1024); err != nil {
74+
tbl.Release()
75+
_ = writer.Close()
76+
return err
77+
}
78+
tbl.Release()
7079
}
71-
defer merged.Release()
72-
return WriteTable(dst, merged, alloc)
80+
return writer.Close()
7381
}
7482

7583
func RewriteEpisodeParquet(ctx context.Context, src string, opts AppendEpisodeOptions, alloc memory.Allocator) (arrow.Table, error) {

internal/parquetx/meta_v30.go

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ import (
1313
)
1414

1515
// WriteTasksParquet writes meta/tasks.parquet compatible with lerobot load_tasks().
16+
// Task strings are stored as the pandas index column named "task".
1617
func WriteTasksParquet(root string, taskMap map[string]int) error {
1718
if len(taskMap) == 0 {
1819
return nil
@@ -41,10 +42,14 @@ func WriteTasksParquet(root string, taskMap map[string]int) error {
4142
defer taskArr.Release()
4243
defer idxArr.Release()
4344

45+
pandasMD := arrow.NewMetadata(
46+
[]string{"pandas"},
47+
[]string{`{"index_columns": ["task"], "column_indexes": [{"name": null, "field_name": null, "pandas_type": "unicode", "numpy_type": "object", "metadata": {"encoding": "UTF-8"}}], "columns": [{"name": "task_index", "field_name": "task_index", "pandas_type": "int64", "numpy_type": "int64", "metadata": null}, {"name": "task", "field_name": "task", "pandas_type": "unicode", "numpy_type": "object", "metadata": null}], "attributes": {}, "pandas_version": "2.3.3"}`},
48+
)
4449
schema := arrow.NewSchema([]arrow.Field{
4550
{Name: "task_index", Type: arrow.PrimitiveTypes.Int64, Nullable: true},
4651
{Name: "task", Type: arrow.BinaryTypes.String, Nullable: true},
47-
}, nil)
52+
}, &pandasMD)
4853
cols := []arrow.Column{
4954
*arrow.NewColumn(schema.Field(0), arrow.NewChunked(schema.Field(0).Type, []arrow.Array{idxArr})),
5055
*arrow.NewColumn(schema.Field(1), arrow.NewChunked(schema.Field(1).Type, []arrow.Array{taskArr})),

internal/parquetx/tasks_test.go

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,9 @@ package parquetx
22

33
import (
44
"context"
5+
"os/exec"
56
"path/filepath"
7+
"strings"
68
"testing"
79

810
"github.com/apache/arrow-go/v18/arrow"
@@ -31,17 +33,33 @@ func TestWriteTasksParquetUsesTaskColumn(t *testing.T) {
3133
if len(tbl.Schema().FieldIndices("task")) == 0 {
3234
t.Fatal("task column missing")
3335
}
34-
if len(tbl.Schema().FieldIndices("__index_level_0__")) != 0 {
35-
t.Fatal("legacy __index_level_0__ column should not be written for new datasets")
36+
if len(tbl.Schema().FieldIndices("task_index")) == 0 {
37+
t.Fatal("task_index column missing")
3638
}
37-
3839
loaded, err := ReadTasksParquet(context.Background(), path)
3940
if err != nil {
4041
t.Fatal(err)
4142
}
4243
if loaded["pick"] != 0 || loaded["place"] != 1 {
4344
t.Fatalf("loaded task map=%v", loaded)
4445
}
46+
47+
if err := exec.Command("python3", "-c", "import pandas").Run(); err != nil {
48+
t.Skip("pandas not installed")
49+
}
50+
out, err := exec.Command("python3", "-c", `
51+
import pandas as pd, sys
52+
tasks = pd.read_parquet(sys.argv[1])
53+
tasks.index.name = "task"
54+
assert tasks.iloc[0].name == "pick", tasks.iloc[0].name
55+
assert tasks.iloc[1].name == "place", tasks.iloc[1].name
56+
`, path).CombinedOutput()
57+
if err != nil {
58+
t.Fatalf("pandas round-trip failed: %v\n%s", err, out)
59+
}
60+
if strings.TrimSpace(string(out)) != "" {
61+
t.Fatalf("unexpected python output: %s", out)
62+
}
4563
}
4664

4765
func TestConcatTablesIgnoresSchemaMetadataDifferences(t *testing.T) {

internal/parquetx/writer.go

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,14 @@ func (w *AppendWriter) WriteEpisodeColumns(columns map[string]any, length int, f
7777
return w.writer.Write(rec)
7878
}
7979

80+
func (w *AppendWriter) WriteRecordColumns(columns map[string]any, length int, features map[string]meta.FeatureSpec) error {
81+
return w.WriteEpisodeColumns(columns, length, features)
82+
}
83+
84+
func (w *AppendWriter) WriteTable(tbl arrow.Table, chunkSize int64) error {
85+
return w.writer.WriteTable(tbl, chunkSize)
86+
}
87+
8088
func (w *AppendWriter) Close() error {
8189
if w.writer != nil {
8290
err := w.writer.Close()

0 commit comments

Comments
 (0)