oopsie-data-tools 0.2.0__py3-none-any.whl
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.
- oopsie_data_tools/__init__.py +7 -0
- oopsie_data_tools/annotation_tool/__init__.py +5 -0
- oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
- oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
- oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
- oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
- oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
- oopsie_data_tools/cli.py +894 -0
- oopsie_data_tools/init_wizard.py +329 -0
- oopsie_data_tools/skill/SKILL.md +98 -0
- oopsie_data_tools/skill/reference/conversion.md +257 -0
- oopsie_data_tools/skill/reference/format.md +112 -0
- oopsie_data_tools/skill/reference/robot-profile.md +104 -0
- oopsie_data_tools/skill/reference/setup.md +216 -0
- oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
- oopsie_data_tools/test/__init__.py +1 -0
- oopsie_data_tools/test/conftest.py +174 -0
- oopsie_data_tools/test/fixtures/__init__.py +0 -0
- oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
- oopsie_data_tools/test/fixtures/make_valid.py +406 -0
- oopsie_data_tools/test/test_annotation_completeness.py +154 -0
- oopsie_data_tools/test/test_annotation_schema.py +190 -0
- oopsie_data_tools/test/test_annotator_server.py +240 -0
- oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
- oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
- oopsie_data_tools/test/test_cli_config.py +111 -0
- oopsie_data_tools/test/test_cli_tools.py +438 -0
- oopsie_data_tools/test/test_contributor_config.py +38 -0
- oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
- oopsie_data_tools/test/test_credentials_location.py +106 -0
- oopsie_data_tools/test/test_diversity.py +33 -0
- oopsie_data_tools/test/test_episode_recorder.py +335 -0
- oopsie_data_tools/test/test_episode_video_paths.py +173 -0
- oopsie_data_tools/test/test_hf_upload.py +234 -0
- oopsie_data_tools/test/test_init_wizard.py +193 -0
- oopsie_data_tools/test/test_install_skill.py +120 -0
- oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
- oopsie_data_tools/test/test_new_profile.py +84 -0
- oopsie_data_tools/test/test_paths.py +137 -0
- oopsie_data_tools/test/test_python38_compat.py +79 -0
- oopsie_data_tools/test/test_record_step_purity.py +127 -0
- oopsie_data_tools/test/test_robot_setup.py +244 -0
- oopsie_data_tools/test/test_rollout_annotator.py +134 -0
- oopsie_data_tools/test/test_rotation_utils.py +126 -0
- oopsie_data_tools/test/test_validate.py +494 -0
- oopsie_data_tools/utils/__init__.py +1 -0
- oopsie_data_tools/utils/claude_skill.py +96 -0
- oopsie_data_tools/utils/contributor_config.py +91 -0
- oopsie_data_tools/utils/conversion_utils.py +220 -0
- oopsie_data_tools/utils/h5.py +60 -0
- oopsie_data_tools/utils/h5_inspect.py +167 -0
- oopsie_data_tools/utils/hf_limits.py +22 -0
- oopsie_data_tools/utils/hf_upload.py +235 -0
- oopsie_data_tools/utils/log.py +35 -0
- oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
- oopsie_data_tools/utils/paths.py +162 -0
- oopsie_data_tools/utils/restructure.py +402 -0
- oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
- oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
- oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
- oopsie_data_tools/utils/robot_profile/template.py +108 -0
- oopsie_data_tools/utils/validation/__init__.py +5 -0
- oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
- oopsie_data_tools/utils/validation/diversity.py +93 -0
- oopsie_data_tools/utils/validation/episode_data.py +54 -0
- oopsie_data_tools/utils/validation/episode_loader.py +201 -0
- oopsie_data_tools/utils/validation/episode_validator.py +315 -0
- oopsie_data_tools/utils/validation/errors.py +17 -0
- oopsie_data_tools/utils/validation/validation_utils.py +88 -0
- oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
- oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
- oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
- oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
- oopsie_data_tools-0.2.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,402 @@
|
|
|
1
|
+
"""Split a session directory with too many files into numbered subfolders.
|
|
2
|
+
|
|
3
|
+
Backs ``oopsie-data restructure``. HuggingFace Hub enforces a per-directory file limit
|
|
4
|
+
(:data:`FILE_LIMIT`), and a long recording session blows past it easily — one HDF5 plus
|
|
5
|
+
one MP4 per camera, per episode. ``hf_upload.check_folder_size`` refuses the upload and
|
|
6
|
+
points here.
|
|
7
|
+
|
|
8
|
+
This is a NON-DESTRUCTIVE operation: files are COPIED to a new output folder and the source
|
|
9
|
+
is never modified — not even the HDF5 files, whose stored video paths are rewritten only in
|
|
10
|
+
the copy. After verifying the output you must delete the original yourself.
|
|
11
|
+
|
|
12
|
+
The output is a complete copy of the source tree in which **every** oversized directory has
|
|
13
|
+
been split: a directory holding 12,000 episodes appears in the output at the same relative
|
|
14
|
+
path but as ``0000/``, ``0500/``, … underneath it, while directories that are already small
|
|
15
|
+
enough are copied through unchanged. Nesting therefore works, including re-running this on
|
|
16
|
+
output it produced.
|
|
17
|
+
|
|
18
|
+
Detection and repair used to disagree — oversized directories were found recursively but
|
|
19
|
+
only the source *root* was ever split, so a source with episodes at the root and an
|
|
20
|
+
oversized subdirectory would restructure the wrong directory and report success, leaving
|
|
21
|
+
the upload just as blocked. Both halves walk the tree now.
|
|
22
|
+
|
|
23
|
+
Video paths stored inside the HDF5 files are resolved to absolute paths before copying, so
|
|
24
|
+
relative paths, absolute paths, and paths containing ``..`` all work. Each video is copied
|
|
25
|
+
flat into the same subfolder as the HDF5 that references it, and the path stored in the
|
|
26
|
+
HDF5 *copy* is rewritten to match.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
import logging
|
|
32
|
+
import os
|
|
33
|
+
import shutil
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
import h5py
|
|
37
|
+
|
|
38
|
+
from oopsie_data_tools.utils.hf_limits import BATCH_SIZE, FILE_LIMIT
|
|
39
|
+
|
|
40
|
+
logger = logging.getLogger(__name__)
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def files_per_dir(root: Path) -> dict[Path, int]:
|
|
44
|
+
"""Count files (non-recursively) in every directory under *root*."""
|
|
45
|
+
return {Path(dirpath): len(filenames) for dirpath, _, filenames in os.walk(root)}
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def oversized_dirs(root: Path) -> list[tuple[Path, int]]:
|
|
49
|
+
return [(d, n) for d, n in files_per_dir(root).items() if n > FILE_LIMIT]
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def collect_h5_files(directory: Path) -> list[Path]:
|
|
53
|
+
"""Return a sorted list of the HDF5 files directly in *directory*."""
|
|
54
|
+
return sorted(
|
|
55
|
+
p for p in directory.iterdir() if p.is_file() and p.suffix.lower() in (".h5", ".hdf5")
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def read_video_paths(h5_path: Path) -> dict[str, str]:
|
|
60
|
+
"""Return ``{camera: stored_path_string}`` from ``observations/video_paths``."""
|
|
61
|
+
paths: dict[str, str] = {}
|
|
62
|
+
try:
|
|
63
|
+
with h5py.File(h5_path, "r") as f:
|
|
64
|
+
vp = f.get("observations/video_paths")
|
|
65
|
+
if vp is None:
|
|
66
|
+
return paths
|
|
67
|
+
for cam in vp.keys():
|
|
68
|
+
raw = vp[cam][()]
|
|
69
|
+
if isinstance(raw, bytes):
|
|
70
|
+
raw = raw.decode("utf-8", errors="replace")
|
|
71
|
+
paths[cam] = str(raw).strip()
|
|
72
|
+
except Exception as exc:
|
|
73
|
+
logger.warning(" ! Could not read video paths from %s: %s", h5_path.name, exc)
|
|
74
|
+
return paths
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def resolve_video_path(stored_path: str, h5_dir: Path) -> Path:
|
|
78
|
+
"""Resolve a stored video path to an absolute Path."""
|
|
79
|
+
p = Path(stored_path)
|
|
80
|
+
return p.resolve() if p.is_absolute() else (h5_dir / p).resolve()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
def write_video_paths(h5_path: Path, new_paths: dict[str, str]) -> None:
|
|
84
|
+
"""Overwrite the video path datasets in the HDF5 file at *h5_path*."""
|
|
85
|
+
str_dtype = h5py.string_dtype(encoding="utf-8")
|
|
86
|
+
with h5py.File(h5_path, "r+") as f:
|
|
87
|
+
vp = f["observations/video_paths"]
|
|
88
|
+
for cam, rel in new_paths.items():
|
|
89
|
+
if cam in vp:
|
|
90
|
+
del vp[cam]
|
|
91
|
+
vp.create_dataset(cam, data=rel, dtype=str_dtype)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def dirs_to_split(source: Path) -> tuple[list[Path], list[Path]]:
|
|
95
|
+
"""Split the oversized directories under *source* into fixable and unfixable.
|
|
96
|
+
|
|
97
|
+
Batching is keyed on HDF5 files, so an oversized directory holding none of them cannot
|
|
98
|
+
be split by this command — a videos-only folder, say. Those are returned separately so
|
|
99
|
+
the caller can say so out loud rather than silently leaving them oversized.
|
|
100
|
+
|
|
101
|
+
Returns:
|
|
102
|
+
``(splittable, unfixable)``, both sorted.
|
|
103
|
+
"""
|
|
104
|
+
splittable, unfixable = [], []
|
|
105
|
+
for directory, _count in sorted(oversized_dirs(source)):
|
|
106
|
+
(splittable if collect_h5_files(directory) else unfixable).append(directory)
|
|
107
|
+
return splittable, unfixable
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def estimate_bytes(source: Path, h5_files: list[Path]) -> int:
|
|
111
|
+
"""Total bytes the output would occupy: the whole source tree, plus any video it
|
|
112
|
+
references from outside it.
|
|
113
|
+
|
|
114
|
+
The output is a full copy of *source*, not just of the episodes being split, so
|
|
115
|
+
estimating from the HDF5 files alone would badly undercount a tree that also holds
|
|
116
|
+
directories small enough to pass through untouched.
|
|
117
|
+
|
|
118
|
+
Each unique absolute path is counted once even if several HDF5 files reference it.
|
|
119
|
+
"""
|
|
120
|
+
total = 0
|
|
121
|
+
seen: set[Path] = set()
|
|
122
|
+
|
|
123
|
+
for dirpath, _dirnames, filenames in os.walk(source):
|
|
124
|
+
for name in filenames:
|
|
125
|
+
p = Path(dirpath) / name
|
|
126
|
+
if p not in seen and p.is_file():
|
|
127
|
+
total += p.stat().st_size
|
|
128
|
+
seen.add(p)
|
|
129
|
+
|
|
130
|
+
# Videos can live outside the source tree entirely (stored paths may contain "..").
|
|
131
|
+
for h5 in h5_files:
|
|
132
|
+
for stored in read_video_paths(h5).values():
|
|
133
|
+
abs_v = resolve_video_path(stored, h5.parent)
|
|
134
|
+
if abs_v not in seen and abs_v.is_file():
|
|
135
|
+
total += abs_v.stat().st_size
|
|
136
|
+
seen.add(abs_v)
|
|
137
|
+
return total
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def subfolder_name(start_idx: int, n_total: int) -> str:
|
|
141
|
+
"""Zero-padded folder name for the batch starting at *start_idx*.
|
|
142
|
+
|
|
143
|
+
All names are padded to the same width so the directories sort correctly.
|
|
144
|
+
Examples for 10 000 files: 0000, 0500, 1000, …, 9500.
|
|
145
|
+
"""
|
|
146
|
+
max_start = ((n_total - 1) // BATCH_SIZE) * BATCH_SIZE
|
|
147
|
+
width = max(3, len(str(max_start)))
|
|
148
|
+
return str(start_idx).zfill(width)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
def _unique_video_dest(
|
|
152
|
+
abs_video: Path,
|
|
153
|
+
cam: str,
|
|
154
|
+
h5_stem: str,
|
|
155
|
+
used: dict[str, Path],
|
|
156
|
+
) -> str:
|
|
157
|
+
"""Return a filename (no directory) for *abs_video* that is unique within *used*.
|
|
158
|
+
|
|
159
|
+
Episode stems are second-resolution timestamps, so two sessions can easily contribute
|
|
160
|
+
the same ``<stem>_<cam>.mp4`` to one batch. This used to return the colliding name
|
|
161
|
+
regardless, silently overwriting the first episode's video during the copy.
|
|
162
|
+
|
|
163
|
+
Re-asking for a video already registered returns the same name, so the function is safe
|
|
164
|
+
to call more than once per file.
|
|
165
|
+
"""
|
|
166
|
+
stem = f"{h5_stem}_{cam}"
|
|
167
|
+
suffix = abs_video.suffix
|
|
168
|
+
|
|
169
|
+
candidate = f"{stem}{suffix}"
|
|
170
|
+
attempt = 1
|
|
171
|
+
while used.get(candidate, abs_video) != abs_video:
|
|
172
|
+
attempt += 1
|
|
173
|
+
candidate = f"{stem}_{attempt}{suffix}"
|
|
174
|
+
|
|
175
|
+
used[candidate] = abs_video
|
|
176
|
+
return candidate
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def restructure(output: Path, h5_files: list[Path]) -> set[Path]:
|
|
180
|
+
"""Batch *h5_files* into numbered subfolders under *output*.
|
|
181
|
+
|
|
182
|
+
Returns:
|
|
183
|
+
The absolute source paths consumed — the HDF5 files and every video they
|
|
184
|
+
reference. :func:`copy_through` skips these so nothing is copied twice.
|
|
185
|
+
"""
|
|
186
|
+
consumed: set[Path] = set()
|
|
187
|
+
n = len(h5_files)
|
|
188
|
+
n_batches = (n + BATCH_SIZE - 1) // BATCH_SIZE
|
|
189
|
+
logger.info(
|
|
190
|
+
"\n[restructure] %d HDF5 file(s) → %d subfolder(s) of up to %d each",
|
|
191
|
+
n, n_batches, BATCH_SIZE,
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
for batch_start in range(0, n, BATCH_SIZE):
|
|
195
|
+
batch = h5_files[batch_start : batch_start + BATCH_SIZE]
|
|
196
|
+
sub = output / subfolder_name(batch_start, n)
|
|
197
|
+
sub.mkdir(parents=True, exist_ok=True)
|
|
198
|
+
logger.info("\n Subfolder %s/ (%d HDF5 files)", sub.name, len(batch))
|
|
199
|
+
|
|
200
|
+
# Which filenames this subfolder already holds, so collisions across HDF5 files in
|
|
201
|
+
# the same batch are detected rather than silently overwriting.
|
|
202
|
+
used_filenames: dict[str, Path] = {}
|
|
203
|
+
|
|
204
|
+
for i, h5_src in enumerate(batch, 1):
|
|
205
|
+
h5_dir = h5_src.parent
|
|
206
|
+
stored_video_paths = read_video_paths(h5_src)
|
|
207
|
+
|
|
208
|
+
# Copy the HDF5 first so the destination exists before we patch it.
|
|
209
|
+
h5_dst = sub / h5_src.name
|
|
210
|
+
shutil.copy2(h5_src, h5_dst)
|
|
211
|
+
consumed.add(h5_src)
|
|
212
|
+
|
|
213
|
+
# Resolve each video to an absolute path, copy it flat into the subfolder, and
|
|
214
|
+
# record what the new relative path will be.
|
|
215
|
+
new_rel_paths: dict[str, str] = {}
|
|
216
|
+
for cam, stored in stored_video_paths.items():
|
|
217
|
+
abs_video = resolve_video_path(stored, h5_dir)
|
|
218
|
+
if not abs_video.exists():
|
|
219
|
+
logger.warning(
|
|
220
|
+
" ! video not found, skipping: %s (ref'd by %s)",
|
|
221
|
+
abs_video, h5_src.name,
|
|
222
|
+
)
|
|
223
|
+
# Leave the original path; validation will catch this later.
|
|
224
|
+
new_rel_paths[cam] = stored
|
|
225
|
+
continue
|
|
226
|
+
|
|
227
|
+
dest_name = _unique_video_dest(abs_video, cam, h5_src.stem, used_filenames)
|
|
228
|
+
shutil.copy2(abs_video, sub / dest_name)
|
|
229
|
+
consumed.add(abs_video)
|
|
230
|
+
# Stored relative to the HDF5 location, which is now the same folder.
|
|
231
|
+
new_rel_paths[cam] = dest_name
|
|
232
|
+
|
|
233
|
+
# Always write the updated paths back into the HDF5 copy so it is
|
|
234
|
+
# self-consistent regardless of how the original paths were formatted.
|
|
235
|
+
if new_rel_paths:
|
|
236
|
+
write_video_paths(h5_dst, new_rel_paths)
|
|
237
|
+
|
|
238
|
+
logger.info(" [%d/%d] %s", i, len(batch), h5_src.name)
|
|
239
|
+
|
|
240
|
+
return consumed
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def copy_through(source: Path, output: Path, consumed: set[Path]) -> int:
|
|
244
|
+
"""Copy every file under *source* that the split did not already place into *output*.
|
|
245
|
+
|
|
246
|
+
Preserves each file's path relative to *source*, so directories that were already small
|
|
247
|
+
enough come through untouched and the output is a complete dataset rather than only the
|
|
248
|
+
episodes that needed splitting.
|
|
249
|
+
|
|
250
|
+
Returns:
|
|
251
|
+
The number of files copied.
|
|
252
|
+
"""
|
|
253
|
+
copied = 0
|
|
254
|
+
for dirpath, dirnames, filenames in os.walk(source):
|
|
255
|
+
here = Path(dirpath)
|
|
256
|
+
# Never descend into the output, in case it was placed inside the source.
|
|
257
|
+
dirnames[:] = [d for d in dirnames if (here / d).resolve() != output]
|
|
258
|
+
for name in filenames:
|
|
259
|
+
src = here / name
|
|
260
|
+
if src in consumed:
|
|
261
|
+
continue
|
|
262
|
+
dst = output / src.relative_to(source)
|
|
263
|
+
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
264
|
+
shutil.copy2(src, dst)
|
|
265
|
+
copied += 1
|
|
266
|
+
return copied
|
|
267
|
+
|
|
268
|
+
|
|
269
|
+
def run_restructure(source: Path, output: Path | None, assume_yes: bool) -> int:
|
|
270
|
+
"""Full interactive flow: check, estimate, confirm, copy. Returns an exit code."""
|
|
271
|
+
source = Path(source).expanduser().resolve()
|
|
272
|
+
if not source.is_dir():
|
|
273
|
+
logger.error("Source is not a directory: %s", source)
|
|
274
|
+
return 1
|
|
275
|
+
|
|
276
|
+
output = (
|
|
277
|
+
Path(output).expanduser().resolve()
|
|
278
|
+
if output
|
|
279
|
+
else source.parent / (source.name + "_restructured")
|
|
280
|
+
)
|
|
281
|
+
|
|
282
|
+
logger.info("=" * 60)
|
|
283
|
+
logger.info(" Large-Folder Restructure Utility")
|
|
284
|
+
logger.info("=" * 60)
|
|
285
|
+
|
|
286
|
+
# ── Step 1: does any directory exceed the file limit? ─────────────────────
|
|
287
|
+
logger.info("\n[check] Scanning %s ...", source)
|
|
288
|
+
over = oversized_dirs(source)
|
|
289
|
+
if not over:
|
|
290
|
+
logger.info("[check] No directory exceeds %d files. No restructuring needed.", FILE_LIMIT)
|
|
291
|
+
return 0
|
|
292
|
+
|
|
293
|
+
logger.info("[check] Director(ies) exceeding %d files:", FILE_LIMIT)
|
|
294
|
+
for d, n in over:
|
|
295
|
+
logger.info(" %s (%d files)", d, n)
|
|
296
|
+
|
|
297
|
+
# ── Step 2: work out which of them we can actually split ──────────────────
|
|
298
|
+
splittable, unfixable = dirs_to_split(source)
|
|
299
|
+
if unfixable:
|
|
300
|
+
logger.warning(
|
|
301
|
+
"[check] These are oversized but hold no HDF5 files, so they cannot be split\n"
|
|
302
|
+
" by episode and are copied through as they are:"
|
|
303
|
+
)
|
|
304
|
+
for d in unfixable:
|
|
305
|
+
logger.warning(" %s", d)
|
|
306
|
+
if not splittable:
|
|
307
|
+
logger.error(
|
|
308
|
+
"[check] No oversized directory under %s contains HDF5 files, so there is\n"
|
|
309
|
+
" nothing to split. Reorganise these by hand.",
|
|
310
|
+
source,
|
|
311
|
+
)
|
|
312
|
+
return 1
|
|
313
|
+
|
|
314
|
+
h5_by_dir = {d: collect_h5_files(d) for d in splittable}
|
|
315
|
+
all_h5 = [h5 for files in h5_by_dir.values() for h5 in files]
|
|
316
|
+
for d, files in h5_by_dir.items():
|
|
317
|
+
logger.info("[check] %d HDF5 file(s) to split in %s", len(files), d)
|
|
318
|
+
|
|
319
|
+
if output == source or source in output.parents:
|
|
320
|
+
logger.error(
|
|
321
|
+
"Output must not be inside the source (%s is under %s); "
|
|
322
|
+
"pick a destination outside it.",
|
|
323
|
+
output, source,
|
|
324
|
+
)
|
|
325
|
+
return 1
|
|
326
|
+
|
|
327
|
+
# ── Step 3: estimate copy size ────────────────────────────────────────────
|
|
328
|
+
logger.info(
|
|
329
|
+
"\n[size] Estimating copy size "
|
|
330
|
+
"(resolving video paths from all HDF5 files — may take a moment) ..."
|
|
331
|
+
)
|
|
332
|
+
total_bytes = estimate_bytes(source, all_h5)
|
|
333
|
+
logger.info("[size] Estimated copy size: %.2f GB", total_bytes / 1e9)
|
|
334
|
+
|
|
335
|
+
# ── Step 4: explicit consent ──────────────────────────────────────────────
|
|
336
|
+
n_batches = sum((len(f) + BATCH_SIZE - 1) // BATCH_SIZE for f in h5_by_dir.values())
|
|
337
|
+
logger.info("\n" + "=" * 60)
|
|
338
|
+
logger.info(" ACTION REQUIRED — please read carefully before proceeding")
|
|
339
|
+
logger.info("=" * 60)
|
|
340
|
+
logger.info(
|
|
341
|
+
"\nThis will CREATE a new folder:\n"
|
|
342
|
+
" %s\n"
|
|
343
|
+
"\nIt will hold a complete copy of the source tree. Each of the %d\n"
|
|
344
|
+
"oversized director(ies) above is replaced by %d subfolder(s) in total,\n"
|
|
345
|
+
"named by starting HDF5 index (e.g. 000/, 500/, 1000/, …), each holding\n"
|
|
346
|
+
"up to %d HDF5 files and the video files they reference. Directories\n"
|
|
347
|
+
"already under the limit are copied through unchanged.\n"
|
|
348
|
+
"\nVideo paths stored inside each HDF5 file will be rewritten to\n"
|
|
349
|
+
"point to the copied video location.\n"
|
|
350
|
+
"\nEstimated disk space needed: %.2f GB\n"
|
|
351
|
+
"\nThe SOURCE FOLDER WILL NOT BE MODIFIED. Once you have verified\n"
|
|
352
|
+
"that the restructured copy is complete and valid, you must\n"
|
|
353
|
+
"MANUALLY DELETE the original source to reclaim space:\n"
|
|
354
|
+
" %s\n"
|
|
355
|
+
" (approx. %.2f GB to free)",
|
|
356
|
+
output, len(splittable), n_batches, BATCH_SIZE,
|
|
357
|
+
total_bytes / 1e9,
|
|
358
|
+
source, total_bytes / 1e9,
|
|
359
|
+
)
|
|
360
|
+
|
|
361
|
+
if output.exists() and any(output.iterdir()):
|
|
362
|
+
logger.warning(
|
|
363
|
+
"\n[warn] Output folder already exists and is not empty:\n"
|
|
364
|
+
" %s\n"
|
|
365
|
+
" Existing files with the same names will be overwritten.",
|
|
366
|
+
output,
|
|
367
|
+
)
|
|
368
|
+
logger.info(
|
|
369
|
+
"\nThis operation is not robust to interruptions! Please ensure that it\n"
|
|
370
|
+
"runs in full. If it is interrupted, delete the output folder and restart."
|
|
371
|
+
)
|
|
372
|
+
|
|
373
|
+
if not assume_yes:
|
|
374
|
+
confirm = input('\nType exactly "yes" to proceed, anything else to abort: ').strip()
|
|
375
|
+
if confirm != "yes":
|
|
376
|
+
logger.info("\nAborted. No files were written.")
|
|
377
|
+
return 0
|
|
378
|
+
|
|
379
|
+
# ── Step 5: split each oversized directory, then copy the rest through ────
|
|
380
|
+
output.mkdir(parents=True, exist_ok=True)
|
|
381
|
+
consumed: set[Path] = set()
|
|
382
|
+
for directory, files in h5_by_dir.items():
|
|
383
|
+
# Each split lands at the directory's own position in the output tree, so the
|
|
384
|
+
# result mirrors the source rather than flattening it.
|
|
385
|
+
logger.info("\n[restructure] %s", directory)
|
|
386
|
+
consumed |= restructure(output / directory.relative_to(source), files)
|
|
387
|
+
|
|
388
|
+
copied = copy_through(source, output, consumed)
|
|
389
|
+
logger.info("\n[copy] %d file(s) copied through unchanged.", copied)
|
|
390
|
+
|
|
391
|
+
logger.info("\n" + "=" * 60)
|
|
392
|
+
logger.info(" Done!")
|
|
393
|
+
logger.info("=" * 60)
|
|
394
|
+
logger.info("\nRestructured data written to:\n %s", output)
|
|
395
|
+
logger.info(
|
|
396
|
+
"\nREMINDER: Verify the restructured folder before deleting the original.\n"
|
|
397
|
+
"To delete the original once satisfied:\n"
|
|
398
|
+
" rm -rf %s\n"
|
|
399
|
+
" (will free approx. %.2f GB)",
|
|
400
|
+
source, total_bytes / 1e9,
|
|
401
|
+
)
|
|
402
|
+
return 0
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
"""Robot profile loading and rotation-representation conversion."""
|
|
@@ -0,0 +1,240 @@
|
|
|
1
|
+
"""Load robot / lab profiles for annotation and episode recording."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import dataclasses
|
|
6
|
+
import json
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional
|
|
9
|
+
|
|
10
|
+
import yaml
|
|
11
|
+
|
|
12
|
+
from oopsie_data_tools.utils.robot_profile.rotation_utils import RotOption
|
|
13
|
+
|
|
14
|
+
ACTION_SPACE_SET_1 = {
|
|
15
|
+
"joint_position",
|
|
16
|
+
"joint_velocity",
|
|
17
|
+
"cartesian_position",
|
|
18
|
+
"cartesian_velocity",
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
ACTION_SPACE_SET_2 = {
|
|
22
|
+
"gripper_position",
|
|
23
|
+
"gripper_velocity",
|
|
24
|
+
"gripper_binary",
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
ACTION_SPACE_SET_3 = {
|
|
28
|
+
"base_velocity",
|
|
29
|
+
"base_position",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
REQUIRED_KEYS = frozenset(
|
|
33
|
+
{
|
|
34
|
+
"policy_name",
|
|
35
|
+
"robot_name",
|
|
36
|
+
"gripper_name",
|
|
37
|
+
"control_freq",
|
|
38
|
+
"is_biarm",
|
|
39
|
+
"uses_mobile_base",
|
|
40
|
+
"camera_names",
|
|
41
|
+
"robot_state_keys",
|
|
42
|
+
"action_space",
|
|
43
|
+
}
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
REQUIRED_ROBOT_STATE_KEYS = frozenset(
|
|
47
|
+
{
|
|
48
|
+
"gripper_position",
|
|
49
|
+
}
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
# The state has to observe whatever space the action controls: velocity commands still
|
|
53
|
+
# need the corresponding *position* state. Applied as a union over the action space, so a
|
|
54
|
+
# profile mixing joint and cartesian actions needs both state keys. Base actions are not
|
|
55
|
+
# covered — nothing yet requires base_position state for a base action.
|
|
56
|
+
ARM_ACTION_REQUIRED_ROBOT_STATE_KEY = {
|
|
57
|
+
"joint_position": "joint_position",
|
|
58
|
+
"joint_velocity": "joint_position",
|
|
59
|
+
"cartesian_position": "cartesian_position",
|
|
60
|
+
"cartesian_velocity": "cartesian_position",
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
@dataclasses.dataclass(frozen=True)
|
|
65
|
+
class RobotProfile:
|
|
66
|
+
"""Robot / dataset identity and recording semantics (paths, joints, cameras).
|
|
67
|
+
|
|
68
|
+
Options specific to :class:`WebRolloutAnnotator` (browser server port, blocking
|
|
69
|
+
until annotation, resuming a session directory) are *not* part of this profile;
|
|
70
|
+
pass those separately when constructing the annotator, e.g. via
|
|
71
|
+
:meth:`WebRolloutAnnotator.from_robot_profile`.
|
|
72
|
+
"""
|
|
73
|
+
policy_name: str
|
|
74
|
+
robot_name: str
|
|
75
|
+
is_biarm: bool
|
|
76
|
+
uses_mobile_base: bool
|
|
77
|
+
gripper_name: str
|
|
78
|
+
control_freq: int
|
|
79
|
+
camera_names: List[str]
|
|
80
|
+
robot_state_keys: List[str]
|
|
81
|
+
robot_state_joint_names: List[str]
|
|
82
|
+
action_space: List[str]
|
|
83
|
+
action_joint_names: Optional[List[str]] = None
|
|
84
|
+
orientation_representation: Optional[str] = None
|
|
85
|
+
robot_state_orientation_representation: Optional[str] = None
|
|
86
|
+
controller: Optional[str] = None
|
|
87
|
+
gains: Optional[Dict[str, Any]] = None
|
|
88
|
+
intrinsic_calibration_matrix: Optional[Dict[str, Any]] = None
|
|
89
|
+
extrinsic_calibration_matrix: Optional[Dict[str, Any]] = None
|
|
90
|
+
|
|
91
|
+
def get_rot_option(self) -> RotOption | None:
|
|
92
|
+
if self.orientation_representation is None:
|
|
93
|
+
return None
|
|
94
|
+
return RotOption.from_string(self.orientation_representation)
|
|
95
|
+
|
|
96
|
+
def get_robot_state_rot_option(self) -> RotOption | None:
|
|
97
|
+
if self.robot_state_orientation_representation is None:
|
|
98
|
+
return None
|
|
99
|
+
return RotOption.from_string(self.robot_state_orientation_representation)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def robot_profile_to_json(profile: RobotProfile) -> str:
|
|
103
|
+
"""Serialize ``RobotProfile`` to a JSON string (for HDF5 file attributes)."""
|
|
104
|
+
return json.dumps(dataclasses.asdict(profile), ensure_ascii=False)
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def load_robot_profile(path: Path | str) -> RobotProfile:
|
|
108
|
+
"""Parse a robot profile YAML file into ``Robotprofile``."""
|
|
109
|
+
p = Path(path)
|
|
110
|
+
if not p.is_file():
|
|
111
|
+
raise FileNotFoundError(f"Robot profile file not found: {p}")
|
|
112
|
+
raw = yaml.safe_load(p.read_text())
|
|
113
|
+
return robot_profile_from_raw(raw)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def robot_profile_from_json(payload: str) -> RobotProfile:
|
|
117
|
+
"""Parse a robot profile JSON string into ``RobotProfile``."""
|
|
118
|
+
try:
|
|
119
|
+
raw = json.loads(payload)
|
|
120
|
+
except Exception as e:
|
|
121
|
+
raise ValueError(f"Invalid robot_profile JSON: {e}") from e
|
|
122
|
+
return robot_profile_from_raw(raw)
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def is_valid_action_space(action_space):
|
|
126
|
+
s = set(action_space)
|
|
127
|
+
|
|
128
|
+
arm_count = len(s & ACTION_SPACE_SET_1)
|
|
129
|
+
gripper_count = len(s & ACTION_SPACE_SET_2)
|
|
130
|
+
base_count = len(s & ACTION_SPACE_SET_3)
|
|
131
|
+
|
|
132
|
+
return (
|
|
133
|
+
arm_count >= 1 and
|
|
134
|
+
gripper_count >= 1 and
|
|
135
|
+
base_count <= 1 and
|
|
136
|
+
len(s) == arm_count + gripper_count + base_count # no extras
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
def robot_profile_from_raw(raw: Any) -> RobotProfile:
|
|
140
|
+
"""Validate and parse raw mapping data into ``RobotProfile``."""
|
|
141
|
+
if not isinstance(raw, dict):
|
|
142
|
+
raise ValueError(f"Robot profile must be a mapping, got {type(raw).__name__}")
|
|
143
|
+
|
|
144
|
+
missing = [k for k in REQUIRED_KEYS if k not in raw]
|
|
145
|
+
if missing:
|
|
146
|
+
raise ValueError(f"Robot profile missing keys: {missing}")
|
|
147
|
+
|
|
148
|
+
action_space = list(raw["action_space"])
|
|
149
|
+
if not is_valid_action_space(action_space):
|
|
150
|
+
raise ValueError(
|
|
151
|
+
f"Invalid action_space: {action_space!r}. "
|
|
152
|
+
"Expected at least 1 arm action from "
|
|
153
|
+
f"{sorted(ACTION_SPACE_SET_1)}, "
|
|
154
|
+
"at least 1 gripper action from "
|
|
155
|
+
f"{sorted(ACTION_SPACE_SET_2)}, "
|
|
156
|
+
"at most 1 base action from "
|
|
157
|
+
f"{sorted(ACTION_SPACE_SET_3)}, "
|
|
158
|
+
"and no other keys."
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
required_robot_state_keys = set(REQUIRED_ROBOT_STATE_KEYS)
|
|
162
|
+
required_robot_state_keys.update(
|
|
163
|
+
ARM_ACTION_REQUIRED_ROBOT_STATE_KEY[action]
|
|
164
|
+
for action in action_space
|
|
165
|
+
if action in ARM_ACTION_REQUIRED_ROBOT_STATE_KEY
|
|
166
|
+
)
|
|
167
|
+
robot_state_keys = list(raw["robot_state_keys"])
|
|
168
|
+
missing_robot_state_keys = sorted(required_robot_state_keys - set(robot_state_keys))
|
|
169
|
+
if missing_robot_state_keys:
|
|
170
|
+
raise ValueError(
|
|
171
|
+
"Robot profile missing robot state keys required by its action_space: "
|
|
172
|
+
f"{missing_robot_state_keys}"
|
|
173
|
+
)
|
|
174
|
+
|
|
175
|
+
robot_state_joint_names = _optional_str_list(
|
|
176
|
+
raw.get("robot_state_joint_names")
|
|
177
|
+
)
|
|
178
|
+
if "joint_position" in robot_state_keys and not robot_state_joint_names:
|
|
179
|
+
raise ValueError(
|
|
180
|
+
"robot_state_joint_names is required when joint_position is included "
|
|
181
|
+
"in robot_state_keys"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
action_joint_names = _optional_str_list(raw.get("action_joint_names"))
|
|
185
|
+
if (
|
|
186
|
+
any(k in {"joint_position", "joint_velocity"} for k in action_space)
|
|
187
|
+
and not action_joint_names
|
|
188
|
+
):
|
|
189
|
+
raise ValueError(
|
|
190
|
+
"action_joint_names is required for joint_position and joint_velocity "
|
|
191
|
+
"action spaces"
|
|
192
|
+
)
|
|
193
|
+
|
|
194
|
+
if bool(raw["uses_mobile_base"]) and set(action_space).isdisjoint(ACTION_SPACE_SET_3):
|
|
195
|
+
raise ValueError(
|
|
196
|
+
f"Invalid action_space {action_space!r} for mobile base: must include at least one of "
|
|
197
|
+
f"{sorted(ACTION_SPACE_SET_3)}"
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
return RobotProfile(
|
|
201
|
+
policy_name=raw["policy_name"],
|
|
202
|
+
robot_name=raw["robot_name"],
|
|
203
|
+
# Both are in REQUIRED_KEYS, so there is no default to fall back on; bool() only
|
|
204
|
+
# normalizes a blank YAML value (``is_biarm:``) into False.
|
|
205
|
+
is_biarm=bool(raw["is_biarm"]),
|
|
206
|
+
uses_mobile_base=bool(raw["uses_mobile_base"]),
|
|
207
|
+
gripper_name=raw["gripper_name"],
|
|
208
|
+
control_freq=raw["control_freq"],
|
|
209
|
+
camera_names=list(raw["camera_names"]),
|
|
210
|
+
# Observation Related
|
|
211
|
+
robot_state_keys=robot_state_keys,
|
|
212
|
+
robot_state_joint_names=robot_state_joint_names or [],
|
|
213
|
+
# Action Related
|
|
214
|
+
action_space=action_space,
|
|
215
|
+
action_joint_names=action_joint_names,
|
|
216
|
+
orientation_representation=raw.get("orientation_representation", None),
|
|
217
|
+
# Optional Keys
|
|
218
|
+
robot_state_orientation_representation=raw.get("robot_state_orientation_representation", None),
|
|
219
|
+
controller=raw.get("controller"),
|
|
220
|
+
gains=raw.get("gains"),
|
|
221
|
+
intrinsic_calibration_matrix=_calibration_matrix(raw, "intrinsic"),
|
|
222
|
+
extrinsic_calibration_matrix=_calibration_matrix(raw, "extrinsic"),
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
def _calibration_matrix(raw: dict[str, Any], kind: str) -> dict[str, Any] | None:
|
|
227
|
+
"""Read a calibration matrix under either spelling.
|
|
228
|
+
|
|
229
|
+
Profiles written from the bundled template use the spaced key
|
|
230
|
+
(``intrinsic calibration matrix``); the underscored key is canonical.
|
|
231
|
+
"""
|
|
232
|
+
return raw.get(f"{kind}_calibration_matrix", raw.get(f"{kind} calibration matrix"))
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
def _optional_str_list(value: Any) -> list[str] | None:
|
|
236
|
+
if value is None:
|
|
237
|
+
return None
|
|
238
|
+
if isinstance(value, list):
|
|
239
|
+
return [str(x) for x in value]
|
|
240
|
+
raise ValueError(f"Expected a list or null, got {type(value).__name__}")
|