Lerobot-Dataset-Ruby 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,362 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'json'
4
+ require 'fileutils'
5
+ require 'parquet'
6
+
7
+ module Lerobot
8
+ module Dataset
9
+ module Ruby
10
+ class Writer
11
+ attr_reader :output_dir, :fps
12
+
13
+ def initialize(output_dir:, fps: 30, tasks: nil)
14
+ @output_dir = File.expand_path(output_dir)
15
+ @fps = fps
16
+ @episodes = []
17
+ @all_frames = []
18
+ @task_map = {}
19
+ @episode_counter = 0
20
+ @next_video_frame_index = 0
21
+ @recording = false
22
+
23
+ Array(tasks).each { |task| task_index(task) }
24
+
25
+ # v3ディレクトリ構造
26
+ FileUtils.mkdir_p(File.join(@output_dir, 'meta'))
27
+ FileUtils.mkdir_p(File.join(@output_dir, 'data', 'chunk-000'))
28
+ FileUtils.mkdir_p(File.join(@output_dir, 'videos', 'rgb', 'chunk-000'))
29
+ FileUtils.mkdir_p(File.join(@output_dir, 'meta', 'episodes', 'chunk-000'))
30
+ end
31
+
32
+ # 録画開始(内部でLibopencvを呼び出す)
33
+ def start_recording(path = nil, fps = nil)
34
+ begin
35
+ require 'libopencv'
36
+ rescue LoadError
37
+ require_relative '../../../../ext/opencv/libopencv'
38
+ end
39
+ path ||= File.join(@output_dir, 'videos', 'rgb', 'chunk-000', 'file-000.mp4')
40
+ Libopencv.start_recording(path, fps || @fps)
41
+ image_bytes = Libopencv.take_picture
42
+ Libopencv.write_frame(image_bytes)
43
+ @recording = true
44
+ puts "録画開始: #{path}"
45
+ end
46
+
47
+ # 録画停止(内部でLibopencvを呼び出す)
48
+ def stop_recording
49
+ if @recording
50
+ Libopencv.stop_recording
51
+ @recording = false
52
+ puts "録画を停止しました"
53
+ end
54
+ end
55
+
56
+ # 新しいエピソードを開始
57
+ def start_episode(task: "Default task")
58
+ @current_episode = {
59
+ episode_index: @episode_counter,
60
+ task: task,
61
+ from_index: @all_frames.length
62
+ }
63
+ @episode_counter += 1
64
+ task_index(task)
65
+ end
66
+
67
+ # フレームを追加(現在のエピソードに)
68
+ def add_frame(action:, state:, image_bytes: nil, timestamp: nil)
69
+ unless @current_episode
70
+ raise RuntimeError, "start_episodeを最初に呼び出してください"
71
+ end
72
+
73
+ episode_frame_index = @all_frames.length - @current_episode[:from_index]
74
+ frame_data = {
75
+ index: @all_frames.length,
76
+ episode_index: @current_episode[:episode_index],
77
+ frame_index: @next_video_frame_index,
78
+ task_index: task_index(@current_episode[:task]),
79
+ action: action,
80
+ state: state,
81
+ timestamp: timestamp || episode_frame_index.to_f / @fps
82
+ }
83
+
84
+ if image_bytes
85
+ # 録画中ならLibopencvに書き込み
86
+ raise RuntimeError, "録画が開始されていません" unless @recording
87
+ Libopencv.write_frame(image_bytes)
88
+ end
89
+
90
+ @all_frames << frame_data
91
+ @next_video_frame_index += 1
92
+ end
93
+
94
+ # エピソードを保存(save: false なら動画だけ残してデータセットから除外)
95
+ def save_episode(save: true)
96
+ unless @current_episode
97
+ return
98
+ end
99
+
100
+ episode = @current_episode
101
+
102
+ unless save
103
+ @all_frames.slice!(episode[:from_index], @all_frames.length - episode[:from_index])
104
+ @all_frames.each_with_index { |frame, index| frame[:index] = index }
105
+ @episode_counter -= 1
106
+ @current_episode = nil
107
+ return
108
+ end
109
+
110
+ episode_length = @all_frames.length - episode[:from_index]
111
+ episode_frames = @all_frames[episode[:from_index], episode_length] || []
112
+ first_video_frame = episode_frames.first ? episode_frames.first[:frame_index] : @next_video_frame_index
113
+ last_video_frame = episode_frames.last ? episode_frames.last[:frame_index] : first_video_frame - 1
114
+
115
+ @episodes << {
116
+ episode_index: episode[:episode_index],
117
+ tasks: [episode[:task]],
118
+ length: episode_length,
119
+ dataset_from_index: episode[:from_index],
120
+ dataset_to_index: @all_frames.length,
121
+ "data/chunk_index" => 0,
122
+ "data/file_index" => 0,
123
+ "videos/rgb/chunk_index" => 0,
124
+ "videos/rgb/file_index" => 0,
125
+ "videos/rgb/from_timestamp" => first_video_frame.to_f / @fps,
126
+ "videos/rgb/to_timestamp" => (last_video_frame + 1).to_f / @fps,
127
+ "meta/episodes/chunk_index" => 0,
128
+ "meta/episodes/file_index" => 0,
129
+ frame_start: episode[:from_index],
130
+ frame_end: @all_frames.length,
131
+ task_index: task_index(episode[:task])
132
+ }
133
+
134
+ @current_episode = nil
135
+ end
136
+
137
+ # 最終保存(Parquet書き込み)
138
+ def save
139
+ if @current_episode
140
+ save_episode
141
+ end
142
+
143
+ ensure_default_video_file
144
+ write_info_json
145
+ write_tasks_parquet
146
+ write_stats_json
147
+ write_episodes_parquet
148
+ write_data_parquet
149
+
150
+ puts "Dataset v3.0 successfully created at: #{@output_dir}"
151
+ end
152
+
153
+ private
154
+
155
+ def task_index(task)
156
+ @task_map[task] ||= @task_map.size
157
+ end
158
+
159
+ def ensure_default_video_file
160
+ video_dir = File.join(@output_dir, 'videos', 'rgb', 'chunk-000')
161
+ video_path = File.join(video_dir, 'file-000.mp4')
162
+ FileUtils.mkdir_p(video_dir)
163
+
164
+ sample_candidates = [
165
+ File.expand_path('../../../../test/test.mp4', __dir__),
166
+ File.expand_path('../../../../public/test.mp4', __dir__),
167
+ File.join(__dir__, 'test', 'test.mp4'),
168
+ File.join(__dir__, 'public', 'test.mp4')
169
+ ]
170
+ sample_path = sample_candidates.find { |path| File.exist?(path) }
171
+
172
+ if !File.exist?(video_path) && sample_path
173
+ FileUtils.cp(sample_path, video_path)
174
+ end
175
+ video_path
176
+ end
177
+
178
+ def write_info_json
179
+ action_size = @all_frames.first&.dig(:action)&.size || 2
180
+ state_size = @all_frames.first&.dig(:state)&.size || 2
181
+
182
+ info = {
183
+ codebase_version: "v3.0",
184
+ fps: @fps,
185
+ total_episodes: @episodes.size,
186
+ total_frames: @all_frames.size,
187
+ total_tasks: @task_map.size,
188
+ chunks_size: 1000,
189
+ data_files_size_in_mb: 100,
190
+ video_files_size_in_mb: 200,
191
+ data_path: "data/chunk-{chunk_index:03d}/file-{file_index:03d}.parquet",
192
+ video_path: "videos/{video_key}/chunk-{chunk_index:03d}/file-{file_index:03d}.mp4",
193
+ features: {
194
+ "index" => { "dtype" => "int64", "shape" => [1] },
195
+ "episode_index" => { "dtype" => "int64", "shape" => [1] },
196
+ "frame_index" => { "dtype" => "int64", "shape" => [1] },
197
+ "task_index" => { "dtype" => "int64", "shape" => [1] },
198
+ "action" => { "dtype" => "float32", "shape" => [action_size] },
199
+ "state" => { "dtype" => "float32", "shape" => [state_size] },
200
+ "timestamp" => { "dtype" => "float32", "shape" => [1] },
201
+ "rgb" => { "dtype" => "video", "shape" => [480, 640, 3], "names" => ["height", "width", "channel"] }
202
+ },
203
+ tasks: @task_map.sort_by { |_, index| index }.map(&:first)
204
+ }
205
+
206
+ File.write(File.join(@output_dir, 'meta', 'info.json'), JSON.pretty_generate(info))
207
+ end
208
+
209
+ def write_tasks_parquet
210
+ parquet_path = File.join(@output_dir, 'meta', 'tasks.parquet')
211
+ schema = Parquet::Schema.define do
212
+ field :task_index, :int64, nullable: false
213
+ field :task, :string, nullable: false
214
+ end
215
+
216
+ rows = @task_map.sort_by { |_, index| index }.map do |task_str, index|
217
+ [index, task_str]
218
+ end
219
+
220
+ Parquet.write_rows(rows, schema: schema, write_to: parquet_path)
221
+ end
222
+
223
+ def write_stats_json
224
+ action_size = @all_frames.first&.dig(:action)&.size || 2
225
+ state_size = @all_frames.first&.dig(:state)&.size || 2
226
+ total_frames = @all_frames.size
227
+
228
+ stats = {
229
+ "index" => {
230
+ "mean" => [0],
231
+ "std" => [1],
232
+ "min" => [0],
233
+ "max" => [1],
234
+ "count" => total_frames
235
+ },
236
+ "episode_index" => {
237
+ "mean" => [0],
238
+ "std" => [1],
239
+ "min" => [0],
240
+ "max" => [1],
241
+ "count" => total_frames
242
+ },
243
+ "frame_index" => {
244
+ "mean" => [0],
245
+ "std" => [1],
246
+ "min" => [0],
247
+ "max" => [1],
248
+ "count" => total_frames
249
+ },
250
+ "task_index" => {
251
+ "mean" => [0],
252
+ "std" => [1],
253
+ "min" => [0],
254
+ "max" => [1],
255
+ "count" => total_frames
256
+ },
257
+ "action" => {
258
+ "mean" => Array.new(action_size, 0.0),
259
+ "std" => Array.new(action_size, 1.0),
260
+ "min" => Array.new(action_size, -1.0),
261
+ "max" => Array.new(action_size, 1.0),
262
+ "count" => total_frames
263
+ },
264
+ "state" => {
265
+ "mean" => Array.new(state_size, 0.0),
266
+ "std" => Array.new(state_size, 1.0),
267
+ "min" => Array.new(state_size, -1.0),
268
+ "max" => Array.new(state_size, 1.0),
269
+ "count" => total_frames
270
+ },
271
+ "timestamp" => {
272
+ "mean" => [0.0],
273
+ "std" => [1.0],
274
+ "min" => [0.0],
275
+ "max" => [1.0],
276
+ "count" => total_frames
277
+ }
278
+ }
279
+
280
+ File.write(File.join(@output_dir, 'meta', 'stats.json'), JSON.pretty_generate(stats))
281
+ end
282
+
283
+ def write_episodes_parquet
284
+ parquet_path = File.join(@output_dir, 'meta', 'episodes', 'chunk-000', 'file-000.parquet')
285
+
286
+ schema = Parquet::Schema.define do
287
+ field :episode_index, :int32, nullable: false
288
+ field :tasks, :list, item: :string
289
+ field :length, :int32, nullable: false
290
+ field :dataset_from_index, :int32, nullable: false
291
+ field :dataset_to_index, :int32, nullable: false
292
+ field :"data/chunk_index", :int32, nullable: false
293
+ field :"data/file_index", :int32, nullable: false
294
+ field :"videos/rgb/chunk_index", :int32, nullable: false
295
+ field :"videos/rgb/file_index", :int32, nullable: false
296
+ field :"videos/rgb/from_timestamp", :float
297
+ field :"videos/rgb/to_timestamp", :float
298
+ field :"meta/episodes/chunk_index", :int32, nullable: false
299
+ field :"meta/episodes/file_index", :int32, nullable: false
300
+ field :frame_start, :int32, nullable: false
301
+ field :frame_end, :int32, nullable: false
302
+ field :task_index, :int64, nullable: false
303
+ end
304
+
305
+ rows = @episodes.map do |ep|
306
+ [
307
+ ep[:episode_index],
308
+ ep[:tasks],
309
+ ep[:length],
310
+ ep[:dataset_from_index],
311
+ ep[:dataset_to_index],
312
+ ep["data/chunk_index"],
313
+ ep["data/file_index"],
314
+ ep["videos/rgb/chunk_index"],
315
+ ep["videos/rgb/file_index"],
316
+ ep["videos/rgb/from_timestamp"],
317
+ ep["videos/rgb/to_timestamp"],
318
+ ep["meta/episodes/chunk_index"],
319
+ ep["meta/episodes/file_index"],
320
+ ep[:frame_start],
321
+ ep[:frame_end],
322
+ ep[:task_index]
323
+ ]
324
+ end
325
+
326
+ Parquet.write_rows(rows, schema: schema, write_to: parquet_path)
327
+ end
328
+
329
+ def write_data_parquet
330
+ parquet_path = File.join(@output_dir, 'data', 'chunk-000', 'file-000.parquet')
331
+
332
+ schema = Parquet::Schema.define do
333
+ field :index, :int64, nullable: false
334
+ field :episode_index, :int64, nullable: false
335
+ field :frame_index, :int64, nullable: false
336
+ field :task_index, :int64, nullable: false
337
+ field :action, :list, item: :float
338
+ field :state, :list, item: :float
339
+ field :timestamp, :float
340
+ end
341
+
342
+ rows = @all_frames.map do |frame|
343
+ [
344
+ frame[:index],
345
+ frame[:episode_index],
346
+ frame[:frame_index],
347
+ frame[:task_index],
348
+ frame[:action],
349
+ frame[:state],
350
+ frame[:timestamp]
351
+ ]
352
+ end
353
+
354
+ Parquet.write_rows(rows, schema: schema, write_to: parquet_path)
355
+ end
356
+ end
357
+ end
358
+ end
359
+ end
360
+
361
+ # 後方互換性のためのエイリアス
362
+ LeRobotDatasetWriter = Lerobot::Dataset::Ruby::Writer
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "Ruby/version"
4
+ require_relative "Ruby/reader"
5
+ require_relative "Ruby/writer"
6
+
7
+ module Lerobot
8
+ module Dataset
9
+ module Ruby
10
+ class Error < StandardError; end
11
+ end
12
+ end
13
+ end
@@ -0,0 +1,8 @@
1
+ module Lerobot
2
+ module Dataset
3
+ module Ruby
4
+ VERSION: String
5
+ # See the writing guide of rbs: https://github.com/ruby/rbs#guides
6
+ end
7
+ end
8
+ end
@@ -0,0 +1,49 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require 'fileutils'
5
+ require_relative '../lerobot_dataset_writer'
6
+ require_relative '../ext/opencv/libopencv'
7
+
8
+ root = File.expand_path('../output_dataset/robot_dataset', __dir__)
9
+ FileUtils.rm_rf(root)
10
+
11
+ writer = LeRobotDatasetWriter.new(output_dir: root, fps: 30)
12
+ writer.start_episode(task: 'Grab the black cube')
13
+
14
+ 3.times do |i|
15
+ writer.add_frame(
16
+ action: [0.1 * (i + 1), 0.2 * (i + 1)],
17
+ state: [1.0, 0.5],
18
+ timestamp: i.to_f / 30.0
19
+ )
20
+ end
21
+
22
+ writer.save_episode
23
+ writer.save
24
+ puts "Ruby dataset generated at: #{root}"
25
+
26
+ python_code = <<~PY
27
+ import json
28
+ from pathlib import Path
29
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
30
+
31
+ root = Path(r"#{root}")
32
+ print("root_exists", root.exists())
33
+ ds = LeRobotDataset(repo_id="lerobot_min_check", root=root)
34
+ print("len", len(ds))
35
+ print("tasks", ds.meta.tasks.to_dict())
36
+ sample = ds[0]
37
+ print("sample_keys", sorted(sample.keys()))
38
+ print("timestamp", sample["timestamp"])
39
+ print("action", sample["action"])
40
+ print("state", sample["state"])
41
+ PY
42
+
43
+ venv_activate = File.expand_path('../.venv/bin/activate', __dir__)
44
+ system(
45
+ 'bash', '-lc',
46
+ "cd #{File.expand_path('..', __dir__)} && . \"#{venv_activate}\" && python - <<'PY'\n#{python_code}\nPY"
47
+ )
48
+
49
+ exit($?.exitstatus)
data/test/sample.rb ADDED
@@ -0,0 +1,56 @@
1
+ require_relative '../lerobot_dataset_writer'
2
+ require_relative '../ext/opencv/libopencv'
3
+
4
+ # データセット書き込みインスタンス作成
5
+ writer = LeRobotDatasetWriter.new(output_dir: File.expand_path('../output_dataset/robot_dataset', __dir__), fps: 30)
6
+
7
+ # カメラをオープン(外部からソースを指定)
8
+ puts "カメラをオープン中..."
9
+ # Libopencv.open(0)
10
+ # Libopencv.open_mp4('./public/test.mp4')
11
+ Libopencv.open_mp4(File.expand_path('../test/test.mp4', __dir__))
12
+
13
+ # 録画開始(ファイル名はWriterのデフォルトを使用)
14
+ writer.start_recording
15
+
16
+ # 10エピソード
17
+ 10.times do |j|
18
+
19
+ # エピソード開始(タスク指定)
20
+ writer.start_episode(task: "Grab the black cube")
21
+
22
+ # 10フレーム撮影・録画・データ保存
23
+ 10.times do |i|
24
+ # カメラから画像取得
25
+ image_bytes = Libopencv.take_picture
26
+
27
+ if image_bytes
28
+ # LeRobot データセットにも保存(v3形式)
29
+ writer.add_frame(
30
+ action: [0.1 * i, 0.2 * i],
31
+ state: [1.0, 0.5],
32
+ image_bytes: image_bytes,
33
+ timestamp: i.to_f / 30
34
+ )
35
+
36
+ puts "フレーム #{i} を保存しました"
37
+ else
38
+ puts "フレーム #{i} の取得に失敗"
39
+ end
40
+ end
41
+
42
+ # エピソード保存
43
+ writer.save_episode
44
+
45
+ end
46
+
47
+ # 録画停止(Libopencvの呼び出しはWriter内部で実行)
48
+ writer.stop_recording
49
+
50
+ # ウィンドウ破棄
51
+ Libopencv.destroy_window
52
+
53
+ # データセット保存(Parquet書き込み)
54
+ writer.save
55
+
56
+ puts "完了!"
@@ -0,0 +1,189 @@
1
+ #!/usr/bin/env python3
2
+ """Validate a local LeRobotDataset v3.0 directory.
3
+
4
+ Usage:
5
+ python3 test_dataset.py output_dataset/my_first_robot_dataset
6
+
7
+ If the ``lerobot`` package is installed, the script also tries to load the
8
+ dataset through LeRobotDataset. PyArrow and OpenCV are used for independent
9
+ structural checks.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import argparse
15
+ import json
16
+ import sys
17
+ from pathlib import Path
18
+
19
+ import pyarrow.parquet as pq
20
+
21
+ try:
22
+ import cv2
23
+ except ImportError:
24
+ cv2 = None
25
+
26
+
27
+ class DatasetValidationError(Exception):
28
+ """Raised when the dataset does not satisfy the expected v3 invariants."""
29
+
30
+
31
+ def load_json(path: Path) -> dict:
32
+ if not path.is_file():
33
+ raise DatasetValidationError(f"Missing file: {path}")
34
+ with path.open(encoding="utf-8") as stream:
35
+ return json.load(stream)
36
+
37
+
38
+ def validate_tasks(dataset_root: Path) -> int:
39
+ tasks_parquet_path = dataset_root / "meta" / "tasks.parquet"
40
+ if not tasks_parquet_path.is_file():
41
+ raise DatasetValidationError(f"Missing file: {tasks_parquet_path}")
42
+
43
+ table = pq.read_table(tasks_parquet_path)
44
+ if "task_index" not in table.column_names and (
45
+ table.schema.pandas_metadata is None or "task_index" not in str(table.schema.pandas_metadata)
46
+ ):
47
+ raise DatasetValidationError("meta/tasks.parquet missing task_index column")
48
+ if len(table) == 0:
49
+ raise DatasetValidationError("meta/tasks.parquet contains no tasks")
50
+ return len(table)
51
+
52
+
53
+ def validate_dataset(dataset_root: Path) -> dict:
54
+ info = load_json(dataset_root / "meta" / "info.json")
55
+ if info.get("codebase_version") != "v3.0":
56
+ raise DatasetValidationError(
57
+ f"Expected codebase_version v3.0, got {info.get('codebase_version')!r}"
58
+ )
59
+
60
+ required_paths = (
61
+ dataset_root / "data" / "chunk-000" / "file-000.parquet",
62
+ dataset_root / "meta" / "episodes" / "chunk-000" / "file-000.parquet",
63
+ )
64
+ for path in required_paths:
65
+ if not path.is_file():
66
+ raise DatasetValidationError(f"Missing file: {path}")
67
+
68
+ task_count = validate_tasks(dataset_root)
69
+ data_table = pq.read_table(required_paths[0])
70
+ episode_table = pq.read_table(required_paths[1])
71
+ data_columns = set(data_table.column_names)
72
+ episode_columns = set(episode_table.column_names)
73
+
74
+ required_data_columns = {"episode_index", "frame_index", "action", "state", "timestamp"}
75
+ required_episode_columns = {"episode_index", "tasks", "length"}
76
+ missing_data = required_data_columns - data_columns
77
+ missing_episodes = required_episode_columns - episode_columns
78
+ if missing_data:
79
+ raise DatasetValidationError(f"Missing data columns: {sorted(missing_data)}")
80
+ if missing_episodes:
81
+ raise DatasetValidationError(
82
+ f"Missing episode columns: {sorted(missing_episodes)}"
83
+ )
84
+
85
+ frame_count = data_table.num_rows
86
+ episode_count = episode_table.num_rows
87
+ declared_frames = info.get("total_frames")
88
+ declared_episodes = info.get("total_episodes")
89
+ if declared_frames != frame_count:
90
+ raise DatasetValidationError(
91
+ f"total_frames={declared_frames} but data contains {frame_count} rows"
92
+ )
93
+ if declared_episodes != episode_count:
94
+ raise DatasetValidationError(
95
+ f"total_episodes={declared_episodes} but episode metadata contains "
96
+ f"{episode_count} rows"
97
+ )
98
+
99
+ lengths = episode_table.column("length").to_pylist()
100
+ if sum(lengths) != frame_count:
101
+ raise DatasetValidationError(
102
+ f"Episode lengths sum to {sum(lengths)}, but data contains {frame_count} rows"
103
+ )
104
+
105
+ video_relative_path = (
106
+ info.get("data_files", {}).get("videos", {}).get("rgb")
107
+ or (
108
+ info.get("video_path")
109
+ and info["video_path"].format(
110
+ video_key="rgb", chunk_index=0, file_index=0
111
+ )
112
+ )
113
+ or "videos/rgb/chunk-000/file-000.mp4"
114
+ )
115
+ if not video_relative_path:
116
+ raise DatasetValidationError("info.json does not define the rgb video path")
117
+ video_path = dataset_root / video_relative_path
118
+ if not video_path.is_file() or video_path.stat().st_size == 0:
119
+ raise DatasetValidationError(f"Missing or empty video: {video_path}")
120
+
121
+ video_frames = None
122
+ video_fps = None
123
+ if cv2 is not None:
124
+ capture = cv2.VideoCapture(str(video_path))
125
+ if not capture.isOpened():
126
+ raise DatasetValidationError(f"OpenCV cannot open video: {video_path}")
127
+ video_frames = int(capture.get(cv2.CAP_PROP_FRAME_COUNT))
128
+ video_fps = capture.get(cv2.CAP_PROP_FPS)
129
+ capture.release()
130
+ if video_frames < frame_count:
131
+ raise DatasetValidationError(
132
+ f"Video contains {video_frames} frames, but data contains {frame_count} rows"
133
+ )
134
+ expected_fps = float(info.get("fps", 0))
135
+ if expected_fps > 0 and abs(video_fps - expected_fps) > 0.5:
136
+ raise DatasetValidationError(
137
+ f"Video FPS is {video_fps}, expected approximately {expected_fps}"
138
+ )
139
+
140
+ return {
141
+ "episodes": episode_count,
142
+ "frames": frame_count,
143
+ "tasks": task_count,
144
+ "video_frames": video_frames,
145
+ "video_fps": video_fps,
146
+ }
147
+
148
+
149
+ def validate_with_lerobot(dataset_root: Path) -> str:
150
+ try:
151
+ from lerobot.datasets.lerobot_dataset import LeRobotDataset
152
+ except ImportError:
153
+ return "LeRobotDataset import skipped: lerobot is not installed"
154
+
155
+ try:
156
+ dataset = LeRobotDataset(repo_id=dataset_root.name, root=str(dataset_root))
157
+ if len(dataset) == 0:
158
+ raise DatasetValidationError("LeRobotDataset loaded zero samples")
159
+ return f"LeRobotDataset loaded successfully ({len(dataset)} samples)"
160
+ except Exception as error:
161
+ raise DatasetValidationError(f"LeRobotDataset load failed: {error}") from error
162
+
163
+
164
+ def main() -> int:
165
+ parser = argparse.ArgumentParser(description=__doc__)
166
+ parser.add_argument(
167
+ "dataset_root",
168
+ nargs="?",
169
+ default="output_dataset/robot_dataset",
170
+ type=Path,
171
+ )
172
+ args = parser.parse_args()
173
+ dataset_root = args.dataset_root.resolve()
174
+
175
+ try:
176
+ result = validate_dataset(dataset_root)
177
+ lerobot_result = validate_with_lerobot(dataset_root)
178
+ except DatasetValidationError as error:
179
+ print(f"INVALID: {error}", file=sys.stderr)
180
+ return 1
181
+
182
+ print(f"VALID: {dataset_root}")
183
+ print(json.dumps(result, indent=2))
184
+ print(lerobot_result)
185
+ return 0
186
+
187
+
188
+ if __name__ == "__main__":
189
+ raise SystemExit(main())