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.
- checksums.yaml +7 -0
- data/CHANGELOG.md +5 -0
- data/CODE_OF_CONDUCT.md +10 -0
- data/LICENSE.txt +21 -0
- data/README.md +43 -0
- data/Rakefile +21 -0
- data/ext/opencv/extconf.rb +7 -0
- data/ext/opencv/libopencv.cpp +281 -0
- data/lib/Lerobot/Dataset/Ruby/reader.rb +174 -0
- data/lib/Lerobot/Dataset/Ruby/version.rb +9 -0
- data/lib/Lerobot/Dataset/Ruby/writer.rb +362 -0
- data/lib/Lerobot/Dataset/Ruby.rb +13 -0
- data/sig/Lerobot/Dataset/Ruby.rbs +8 -0
- data/test/minimal_lerobot_check.rb +49 -0
- data/test/sample.rb +56 -0
- data/test/test_dataset.py +189 -0
- data/test/test_dataset_reader.rb +95 -0
- data/test/test_helper.rb +6 -0
- data/test/test_libopencv.rb +52 -0
- metadata +108 -0
|
@@ -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,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())
|