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,220 @@
|
|
|
1
|
+
"""Shared utilities for dataset-to-HDF5 conversion scripts.
|
|
2
|
+
|
|
3
|
+
A converter writes ``oopsiedata_format_v1`` directly instead of going through
|
|
4
|
+
:class:`~oopsie_data_tools.annotation_tool.episode_recorder.EpisodeRecorder`, so none of the
|
|
5
|
+
recording-time checks run. These helpers exist so the parts that are easy to get silently
|
|
6
|
+
wrong — the annotation layout and the image bounds — come from the same definitions the
|
|
7
|
+
validator uses. See ``skill/reference/conversion.md``.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any, Sequence
|
|
14
|
+
|
|
15
|
+
import cv2
|
|
16
|
+
import h5py
|
|
17
|
+
import numpy as np
|
|
18
|
+
|
|
19
|
+
from oopsie_data_tools.annotation_tool.annotation_schema import (
|
|
20
|
+
OUTCOME_SUCCESS,
|
|
21
|
+
OUTCOMES,
|
|
22
|
+
SUCCESS_THRESHOLD,
|
|
23
|
+
success_to_outcome,
|
|
24
|
+
write_annotation_attrs,
|
|
25
|
+
)
|
|
26
|
+
from oopsie_data_tools.utils.h5 import decode_h5_scalar
|
|
27
|
+
from oopsie_data_tools.utils.robot_profile.robot_profile import (
|
|
28
|
+
RobotProfile,
|
|
29
|
+
robot_profile_to_json,
|
|
30
|
+
)
|
|
31
|
+
from oopsie_data_tools.utils.validation.episode_loader import OOPSIE_DATA_SCHEMA_V1
|
|
32
|
+
from oopsie_data_tools.utils.validation.episode_validator import (
|
|
33
|
+
MAX_IMAGE_SIZE,
|
|
34
|
+
MIN_IMAGE_SIZE,
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
#: Kept as an alias so existing converters keep working. Imported from the validator rather
|
|
38
|
+
#: than restated: a local copy drifted to 1080 once already, which silently downscaled every
|
|
39
|
+
#: video for a limit that was never 1080.
|
|
40
|
+
SCHEMA_VERSION = OOPSIE_DATA_SCHEMA_V1
|
|
41
|
+
MAX_DIM = MAX_IMAGE_SIZE
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
def _resize_frames(frames: np.ndarray, max_dim: int = MAX_DIM) -> np.ndarray:
|
|
45
|
+
"""Resize ``(T, H, W, 3)`` frames so that ``max(H, W) <= max_dim``.
|
|
46
|
+
|
|
47
|
+
Raises:
|
|
48
|
+
ValueError: If the result would fall below the validator's minimum side, which
|
|
49
|
+
would produce an episode rejected at upload rather than here.
|
|
50
|
+
"""
|
|
51
|
+
h, w = frames.shape[1:3]
|
|
52
|
+
if w <= max_dim and h <= max_dim:
|
|
53
|
+
new_h, new_w = h, w
|
|
54
|
+
else:
|
|
55
|
+
scale = max_dim / max(w, h)
|
|
56
|
+
new_w, new_h = int(w * scale), int(h * scale)
|
|
57
|
+
|
|
58
|
+
if min(new_w, new_h) < MIN_IMAGE_SIZE:
|
|
59
|
+
raise ValueError(
|
|
60
|
+
f"Frames are {w}x{h}; at most {max_dim}px on the long side that becomes "
|
|
61
|
+
f"{new_w}x{new_h}, whose short side is under the {MIN_IMAGE_SIZE}px minimum "
|
|
62
|
+
"the validator enforces. Crop or re-render the source video instead."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
if (new_w, new_h) == (w, h):
|
|
66
|
+
return frames
|
|
67
|
+
return np.stack(
|
|
68
|
+
[cv2.resize(f, (new_w, new_h), interpolation=cv2.INTER_AREA) for f in frames]
|
|
69
|
+
)
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
def _parse_fps(control_freq: Any, default_fps: float = 15.0) -> float:
|
|
73
|
+
"""Coerce a profile's ``control_freq`` to a usable FPS, falling back on nonsense."""
|
|
74
|
+
try:
|
|
75
|
+
parsed = float(control_freq)
|
|
76
|
+
except (TypeError, ValueError):
|
|
77
|
+
return default_fps
|
|
78
|
+
return parsed if parsed > 0 else default_fps
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def _decode_text(value: Any) -> str:
|
|
82
|
+
"""Decode bytes, numpy scalars/arrays, or arbitrary values to a plain str."""
|
|
83
|
+
# Thin alias: decode_h5_scalar handles every case this did, plus None and str.
|
|
84
|
+
return decode_h5_scalar(value)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def write_root_attrs(
|
|
88
|
+
file_handle: h5py.File,
|
|
89
|
+
*,
|
|
90
|
+
episode_id: str,
|
|
91
|
+
language_instruction: str,
|
|
92
|
+
lab_id: str,
|
|
93
|
+
operator_name: str,
|
|
94
|
+
robot_profile: RobotProfile,
|
|
95
|
+
timestamp: float | None = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
"""Write the six required root attributes, plus the optional ``timestamp``.
|
|
98
|
+
|
|
99
|
+
All six are checked by the loader before anything else is read, and four of them are
|
|
100
|
+
additionally checked for emptiness. ``lab_id`` must be the real one from registration —
|
|
101
|
+
``"your_lab_id"`` is rejected by name.
|
|
102
|
+
"""
|
|
103
|
+
str_dtype = h5py.string_dtype(encoding="utf-8")
|
|
104
|
+
file_handle.attrs["schema"] = SCHEMA_VERSION
|
|
105
|
+
file_handle.attrs["episode_id"] = episode_id
|
|
106
|
+
file_handle.attrs["language_instruction"] = language_instruction
|
|
107
|
+
file_handle.attrs["lab_id"] = lab_id
|
|
108
|
+
file_handle.attrs["operator_name"] = operator_name
|
|
109
|
+
file_handle.attrs.create(
|
|
110
|
+
"robot_profile", robot_profile_to_json(robot_profile), dtype=str_dtype
|
|
111
|
+
)
|
|
112
|
+
if timestamp is not None:
|
|
113
|
+
file_handle.attrs["timestamp"] = float(timestamp)
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def write_episode_annotations(
|
|
117
|
+
file_handle: h5py.File,
|
|
118
|
+
*,
|
|
119
|
+
annotator_name: str,
|
|
120
|
+
success: float,
|
|
121
|
+
outcome: str | None = None,
|
|
122
|
+
episode_description: str = "",
|
|
123
|
+
side_effect_category: Sequence[str] | None = None,
|
|
124
|
+
severity: str = "",
|
|
125
|
+
additional_notes: str = "",
|
|
126
|
+
timestamp: str = "",
|
|
127
|
+
) -> None:
|
|
128
|
+
"""Write one annotator's annotation into ``episode_annotations/<annotator_name>/``.
|
|
129
|
+
|
|
130
|
+
The per-annotator subgroup is not optional: the loader iterates the subgroups of
|
|
131
|
+
``episode_annotations`` and reads attributes off each one, so attributes written on the
|
|
132
|
+
parent group are invisible and the episode fails as unannotated. Identity fields
|
|
133
|
+
(``lab_id``, ``operator_name``) are root attributes — see :func:`write_root_attrs` —
|
|
134
|
+
not annotation fields.
|
|
135
|
+
|
|
136
|
+
``success`` is stored as the exact float given, so qualified successes survive; it is
|
|
137
|
+
authoritative for magnitude, and ``outcome`` for which of the four branches this is.
|
|
138
|
+
Omit ``outcome`` and it is derived from the float, which can only ever yield the coarse
|
|
139
|
+
``success``/``failure`` — pass it explicitly to record a qualified success.
|
|
140
|
+
|
|
141
|
+
Every taxonomy field is optional: a partial annotation is valid.
|
|
142
|
+
"""
|
|
143
|
+
if not 0.0 <= float(success) <= 1.0:
|
|
144
|
+
raise ValueError(f"success must be in [0.0, 1.0], got {success!r}")
|
|
145
|
+
|
|
146
|
+
resolved_outcome = (outcome or "").strip().lower() or success_to_outcome(success)
|
|
147
|
+
if resolved_outcome not in OUTCOME_SUCCESS:
|
|
148
|
+
raise ValueError(f"outcome must be one of {OUTCOMES}, got {outcome!r}")
|
|
149
|
+
# A float and a slug that disagree would make every downstream reader pick a different
|
|
150
|
+
# answer depending on which one it happens to consult.
|
|
151
|
+
if (resolved_outcome == "failure") != (float(success) < SUCCESS_THRESHOLD):
|
|
152
|
+
raise ValueError(
|
|
153
|
+
f"outcome={resolved_outcome!r} disagrees with success={success!r} "
|
|
154
|
+
f"(threshold {SUCCESS_THRESHOLD})"
|
|
155
|
+
)
|
|
156
|
+
|
|
157
|
+
group = file_handle.require_group("episode_annotations").require_group(annotator_name)
|
|
158
|
+
write_annotation_attrs(
|
|
159
|
+
group,
|
|
160
|
+
{
|
|
161
|
+
"outcome": resolved_outcome,
|
|
162
|
+
"timestamp": timestamp,
|
|
163
|
+
"episode_description": episode_description,
|
|
164
|
+
"side_effect_category": list(side_effect_category or []),
|
|
165
|
+
"severity": severity,
|
|
166
|
+
"additional_notes": additional_notes,
|
|
167
|
+
},
|
|
168
|
+
)
|
|
169
|
+
# write_annotation_attrs derives a 1.0/0.0 success from the outcome; restore the exact
|
|
170
|
+
# value so a fractional success survives.
|
|
171
|
+
group.attrs["success"] = float(success)
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
def write_actions(
|
|
175
|
+
file_handle: h5py.File,
|
|
176
|
+
actions: dict[str, np.ndarray],
|
|
177
|
+
action_space: Sequence[str],
|
|
178
|
+
) -> None:
|
|
179
|
+
"""Write ``/actions``: real arrays for ``action_space``, ``h5py.Empty`` for the rest.
|
|
180
|
+
|
|
181
|
+
Every canonical key must exist as a dataset. A key in ``action_space`` stored as Empty is
|
|
182
|
+
rejected, and a non-empty dataset the profile does not declare is rejected too, so the
|
|
183
|
+
split has to follow the profile exactly.
|
|
184
|
+
"""
|
|
185
|
+
from oopsie_data_tools.annotation_tool.episode_recorder import VALID_ACTION_KEYS
|
|
186
|
+
|
|
187
|
+
declared = set(action_space)
|
|
188
|
+
unknown = declared - set(VALID_ACTION_KEYS)
|
|
189
|
+
if unknown:
|
|
190
|
+
raise ValueError(f"action_space contains unrecognized keys: {sorted(unknown)}")
|
|
191
|
+
missing = declared - set(actions)
|
|
192
|
+
if missing:
|
|
193
|
+
raise ValueError(f"No action array supplied for declared keys: {sorted(missing)}")
|
|
194
|
+
|
|
195
|
+
group = file_handle.create_group("actions")
|
|
196
|
+
for key in sorted(VALID_ACTION_KEYS):
|
|
197
|
+
if key in declared:
|
|
198
|
+
group.create_dataset(key, data=np.asarray(actions[key]), dtype=np.float64)
|
|
199
|
+
else:
|
|
200
|
+
group.create_dataset(key, data=h5py.Empty(dtype=np.float64))
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def write_video_paths(
|
|
204
|
+
file_handle: h5py.File,
|
|
205
|
+
video_paths: dict[str, str],
|
|
206
|
+
h5_path: Path | str,
|
|
207
|
+
) -> None:
|
|
208
|
+
"""Write ``/observations/video_paths`` as paths relative to the episode file."""
|
|
209
|
+
import os
|
|
210
|
+
|
|
211
|
+
str_dtype = h5py.string_dtype(encoding="utf-8")
|
|
212
|
+
episode_dir = Path(h5_path).resolve().parent
|
|
213
|
+
observations = file_handle.require_group("observations")
|
|
214
|
+
group = observations.require_group("video_paths")
|
|
215
|
+
for cam, raw in video_paths.items():
|
|
216
|
+
target = Path(raw).expanduser()
|
|
217
|
+
if not target.is_absolute():
|
|
218
|
+
target = episode_dir / target
|
|
219
|
+
rel = os.path.relpath(target.resolve(), start=episode_dir)
|
|
220
|
+
group.create_dataset(cam, data=rel.replace(os.sep, "/"), dtype=str_dtype)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
"""Small HDF5 helpers shared by the package and the scripts.
|
|
2
|
+
|
|
3
|
+
Deliberately dependency-light — numpy only, no h5py or cv2 — so conversion and inspection
|
|
4
|
+
scripts can use it without pulling in the validation stack.
|
|
5
|
+
|
|
6
|
+
Both helpers replaced a family of near-identical local copies: four separate
|
|
7
|
+
bytes-to-string decoders across the scripts, and three hand-rolled episode globs that
|
|
8
|
+
disagreed about whether ``.hdf5`` counts.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any, Iterable
|
|
15
|
+
|
|
16
|
+
import numpy as np
|
|
17
|
+
|
|
18
|
+
#: Extensions an episode file may carry. ``validate`` accepts both.
|
|
19
|
+
EPISODE_SUFFIXES = (".h5", ".hdf5")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
def decode_h5_scalar(value: Any) -> str:
|
|
23
|
+
"""Decode an HDF5 attribute or dataset scalar to a plain ``str``.
|
|
24
|
+
|
|
25
|
+
Handles the shapes h5py hands back depending on how a value was written: raw ``bytes``,
|
|
26
|
+
``str``, numpy scalars, and 0-d or single-element arrays. Anything else is stringified,
|
|
27
|
+
so this never raises.
|
|
28
|
+
"""
|
|
29
|
+
if value is None:
|
|
30
|
+
return ""
|
|
31
|
+
if isinstance(value, bytes):
|
|
32
|
+
return value.decode("utf-8", errors="replace")
|
|
33
|
+
if isinstance(value, str):
|
|
34
|
+
return value
|
|
35
|
+
if isinstance(value, np.generic):
|
|
36
|
+
return decode_h5_scalar(value.item())
|
|
37
|
+
if isinstance(value, np.ndarray):
|
|
38
|
+
if value.shape == ():
|
|
39
|
+
return decode_h5_scalar(value.item())
|
|
40
|
+
if value.size == 0:
|
|
41
|
+
return ""
|
|
42
|
+
return decode_h5_scalar(value.reshape(-1)[0])
|
|
43
|
+
return str(value)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def find_episode_files(
|
|
47
|
+
root: Path | str, suffixes: Iterable[str] = EPISODE_SUFFIXES
|
|
48
|
+
) -> list[Path]:
|
|
49
|
+
"""Every episode file under ``root``, recursively, in a stable order.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
root: Directory to search.
|
|
53
|
+
suffixes: Extensions to accept. Pass a narrower set where downstream code only
|
|
54
|
+
handles one — the annotation server's path guard accepts ``.h5`` alone.
|
|
55
|
+
"""
|
|
56
|
+
root = Path(root)
|
|
57
|
+
wanted = {s.lower() for s in suffixes}
|
|
58
|
+
return sorted(
|
|
59
|
+
p for p in root.rglob("*") if p.is_file() and p.suffix.lower() in wanted
|
|
60
|
+
)
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
"""Human-readable dump of an HDF5 file: groups, datasets, shapes, dtypes, attributes.
|
|
2
|
+
|
|
3
|
+
Backs ``oopsie-data inspect``. This is a debugging aid for looking at a recorded episode,
|
|
4
|
+
not a validator — it never rejects anything and makes no assumptions about the schema, so
|
|
5
|
+
it is equally useful on a file that fails ``oopsie-data validate``.
|
|
6
|
+
|
|
7
|
+
Output goes to stdout via ``print`` rather than the logger: it is the command's result,
|
|
8
|
+
not a progress report.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import datetime as _dt
|
|
14
|
+
import math
|
|
15
|
+
from pprint import pformat
|
|
16
|
+
from typing import Any
|
|
17
|
+
|
|
18
|
+
import h5py
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from oopsie_data_tools.utils.h5 import decode_h5_scalar
|
|
22
|
+
|
|
23
|
+
_MAX_STR = 200 # truncation width for string scalars
|
|
24
|
+
_MAX_ELEMS = 32 # array elements shown before eliding
|
|
25
|
+
_MAX_LIST = 16 # list entries shown before eliding
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def _human_bytes(n: int | None) -> str:
|
|
29
|
+
if n is None:
|
|
30
|
+
return "?"
|
|
31
|
+
if n < 1024:
|
|
32
|
+
return f"{n} B"
|
|
33
|
+
units = ["KiB", "MiB", "GiB", "TiB", "PiB"]
|
|
34
|
+
f = float(n)
|
|
35
|
+
for u in units:
|
|
36
|
+
f /= 1024.0
|
|
37
|
+
if f < 1024.0:
|
|
38
|
+
return f"{f:.2f} {u}"
|
|
39
|
+
return f"{f:.2f} EiB"
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def _truncate(s: str) -> str:
|
|
43
|
+
return s[:_MAX_STR] + "…" if len(s) > _MAX_STR else s
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _fmt_scalar(v: Any) -> str:
|
|
47
|
+
if isinstance(v, (np.generic,)):
|
|
48
|
+
try:
|
|
49
|
+
v = v.item()
|
|
50
|
+
except Exception:
|
|
51
|
+
pass
|
|
52
|
+
if isinstance(v, bytes):
|
|
53
|
+
return repr(_truncate(decode_h5_scalar(v)))
|
|
54
|
+
if isinstance(v, str):
|
|
55
|
+
return repr(_truncate(v))
|
|
56
|
+
if isinstance(v, (_dt.datetime, _dt.date)):
|
|
57
|
+
return v.isoformat()
|
|
58
|
+
return repr(v)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def _fmt_array(a: np.ndarray, *, max_elems: int = _MAX_ELEMS) -> str:
|
|
62
|
+
# Keep output stable and short for big arrays.
|
|
63
|
+
if a.size == 0:
|
|
64
|
+
return f"array(shape={a.shape}, dtype={a.dtype}, empty)"
|
|
65
|
+
|
|
66
|
+
flat = a.ravel()
|
|
67
|
+
head = flat[: min(flat.size, max_elems)]
|
|
68
|
+
suffix = f", … (+{flat.size - max_elems} more)" if flat.size > max_elems else ""
|
|
69
|
+
|
|
70
|
+
try:
|
|
71
|
+
content = np.array2string(head, threshold=max_elems, edgeitems=math.inf)
|
|
72
|
+
except Exception:
|
|
73
|
+
content = repr(head)
|
|
74
|
+
|
|
75
|
+
return f"array(shape={a.shape}, dtype={a.dtype}, head={content}{suffix})"
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def _fmt_attr_value(v: Any) -> str:
|
|
79
|
+
# h5py may return scalars, bytes, numpy arrays, or lists.
|
|
80
|
+
if isinstance(v, np.ndarray):
|
|
81
|
+
return _fmt_array(v)
|
|
82
|
+
if isinstance(v, (list, tuple)):
|
|
83
|
+
if len(v) == 0:
|
|
84
|
+
return "[]"
|
|
85
|
+
if len(v) <= _MAX_LIST:
|
|
86
|
+
return pformat([_fmt_scalar(x) for x in v])
|
|
87
|
+
head = [_fmt_scalar(x) for x in v[:_MAX_LIST]]
|
|
88
|
+
return pformat(head)[:-1] + f", … (+{len(v) - _MAX_LIST} more)]"
|
|
89
|
+
return _fmt_scalar(v)
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _print_attrs(obj: h5py.Group | h5py.Dataset, *, indent: int) -> None:
|
|
93
|
+
if len(obj.attrs) == 0:
|
|
94
|
+
return
|
|
95
|
+
print(" " * indent + "attrs:")
|
|
96
|
+
for k in sorted(obj.attrs.keys()):
|
|
97
|
+
try:
|
|
98
|
+
v = obj.attrs[k]
|
|
99
|
+
except Exception as e:
|
|
100
|
+
print(" " * (indent + 2) + f"- {k!r}: <error reading attr: {e}>")
|
|
101
|
+
continue
|
|
102
|
+
print(" " * (indent + 2) + f"- {k!r}: {_fmt_attr_value(v)}")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def _describe_dataset(ds: h5py.Dataset) -> str:
|
|
106
|
+
parts = [f"shape={ds.shape}", f"dtype={ds.dtype}"]
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
if ds.chunks is not None:
|
|
110
|
+
parts.append(f"chunks={ds.chunks}")
|
|
111
|
+
except Exception:
|
|
112
|
+
pass
|
|
113
|
+
|
|
114
|
+
try:
|
|
115
|
+
comp = ds.compression
|
|
116
|
+
if comp is not None:
|
|
117
|
+
parts.append(f"compression={comp!r}")
|
|
118
|
+
except Exception:
|
|
119
|
+
pass
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
fill = ds.fillvalue
|
|
123
|
+
if fill is not None:
|
|
124
|
+
parts.append(f"fill={_fmt_scalar(fill)}")
|
|
125
|
+
except Exception:
|
|
126
|
+
pass
|
|
127
|
+
|
|
128
|
+
try:
|
|
129
|
+
nbytes = int(ds.size) * int(ds.dtype.itemsize)
|
|
130
|
+
parts.append(f"approx_nbytes={_human_bytes(nbytes)}")
|
|
131
|
+
except Exception:
|
|
132
|
+
pass
|
|
133
|
+
|
|
134
|
+
return ", ".join(parts)
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def _walk(name: str, obj: h5py.Group | h5py.Dataset, *, indent: int) -> None:
|
|
138
|
+
if isinstance(obj, h5py.Group):
|
|
139
|
+
title = "/" if name == "" else name
|
|
140
|
+
print(" " * indent + f"[group] {title}")
|
|
141
|
+
_print_attrs(obj, indent=indent + 2)
|
|
142
|
+
|
|
143
|
+
for k in sorted(obj.keys()):
|
|
144
|
+
# Show links explicitly rather than following them (common in some layouts).
|
|
145
|
+
try:
|
|
146
|
+
link = obj.get(k, getlink=True)
|
|
147
|
+
if isinstance(link, (h5py.SoftLink, h5py.ExternalLink)):
|
|
148
|
+
print(" " * (indent + 2) + f"[link] {title.rstrip('/')}/{k} -> {link}")
|
|
149
|
+
continue
|
|
150
|
+
except Exception:
|
|
151
|
+
pass
|
|
152
|
+
|
|
153
|
+
child_name = (title.rstrip("/") + "/" + k) if title != "/" else ("/" + k)
|
|
154
|
+
_walk(child_name, obj[k], indent=indent + 2)
|
|
155
|
+
|
|
156
|
+
elif isinstance(obj, h5py.Dataset):
|
|
157
|
+
print(" " * indent + f"[dataset] {name} ({_describe_dataset(obj)})")
|
|
158
|
+
_print_attrs(obj, indent=indent + 2)
|
|
159
|
+
else:
|
|
160
|
+
print(" " * indent + f"[unknown] {name}: {type(obj)}")
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def inspect_h5(path: str) -> None:
|
|
164
|
+
"""Print the full structure of the HDF5 file at *path* to stdout."""
|
|
165
|
+
with h5py.File(path, "r") as f:
|
|
166
|
+
print(f"HDF5: {path}")
|
|
167
|
+
_walk("", f, indent=0)
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
"""HuggingFace Hub layout limits, and how we split a session to stay under them.
|
|
2
|
+
|
|
3
|
+
These live apart from :mod:`oopsie_data_tools.utils.restructure` — their natural home —
|
|
4
|
+
only so that ``oopsie-data --help`` can quote the numbers without importing h5py, which
|
|
5
|
+
costs ~280 ms and is pure waste when all the user asked for was help text. Everything
|
|
6
|
+
else imports them from here too, so there is still exactly one definition of each.
|
|
7
|
+
|
|
8
|
+
Modules bind these names at import time, so a test that patches ``restructure.FILE_LIMIT``
|
|
9
|
+
changes the limit only for that module. Patch each module you want affected.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
#: Maximum files HuggingFace Hub accepts in a single directory. Exceeding it fails the
|
|
15
|
+
#: upload at the Hub, so ``hf_upload.check_folder_size`` refuses before transferring
|
|
16
|
+
#: anything.
|
|
17
|
+
FILE_LIMIT = 10_000
|
|
18
|
+
|
|
19
|
+
#: Episodes per numbered subfolder when splitting an oversized directory. A batch is this
|
|
20
|
+
#: many HDF5 files plus their videos, so it stays under FILE_LIMIT as long as episodes have
|
|
21
|
+
#: fewer than FILE_LIMIT / BATCH_SIZE - 1 cameras — roughly 19 at the values above.
|
|
22
|
+
BATCH_SIZE = 500
|