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.
Files changed (74) hide show
  1. oopsie_data_tools/__init__.py +7 -0
  2. oopsie_data_tools/annotation_tool/__init__.py +5 -0
  3. oopsie_data_tools/annotation_tool/annotation_schema.py +266 -0
  4. oopsie_data_tools/annotation_tool/annotator_server.py +850 -0
  5. oopsie_data_tools/annotation_tool/episode_recorder.py +648 -0
  6. oopsie_data_tools/annotation_tool/rollout_annotator.py +333 -0
  7. oopsie_data_tools/annotation_tool/ui/annotator.html +2240 -0
  8. oopsie_data_tools/cli.py +894 -0
  9. oopsie_data_tools/init_wizard.py +329 -0
  10. oopsie_data_tools/skill/SKILL.md +98 -0
  11. oopsie_data_tools/skill/reference/conversion.md +257 -0
  12. oopsie_data_tools/skill/reference/format.md +112 -0
  13. oopsie_data_tools/skill/reference/robot-profile.md +104 -0
  14. oopsie_data_tools/skill/reference/setup.md +216 -0
  15. oopsie_data_tools/skill/reference/troubleshooting.md +97 -0
  16. oopsie_data_tools/test/__init__.py +1 -0
  17. oopsie_data_tools/test/conftest.py +174 -0
  18. oopsie_data_tools/test/fixtures/__init__.py +0 -0
  19. oopsie_data_tools/test/fixtures/make_invalid.py +633 -0
  20. oopsie_data_tools/test/fixtures/make_valid.py +406 -0
  21. oopsie_data_tools/test/test_annotation_completeness.py +154 -0
  22. oopsie_data_tools/test/test_annotation_schema.py +190 -0
  23. oopsie_data_tools/test/test_annotator_server.py +240 -0
  24. oopsie_data_tools/test/test_annotator_server_guards.py +146 -0
  25. oopsie_data_tools/test/test_bulk_inference_end_to_end.py +112 -0
  26. oopsie_data_tools/test/test_cli_config.py +111 -0
  27. oopsie_data_tools/test/test_cli_tools.py +438 -0
  28. oopsie_data_tools/test/test_contributor_config.py +38 -0
  29. oopsie_data_tools/test/test_conversion_utils_annotations.py +75 -0
  30. oopsie_data_tools/test/test_credentials_location.py +106 -0
  31. oopsie_data_tools/test/test_diversity.py +33 -0
  32. oopsie_data_tools/test/test_episode_recorder.py +335 -0
  33. oopsie_data_tools/test/test_episode_video_paths.py +173 -0
  34. oopsie_data_tools/test/test_hf_upload.py +234 -0
  35. oopsie_data_tools/test/test_init_wizard.py +193 -0
  36. oopsie_data_tools/test/test_install_skill.py +120 -0
  37. oopsie_data_tools/test/test_migrate_taxonomy_v2.py +255 -0
  38. oopsie_data_tools/test/test_new_profile.py +84 -0
  39. oopsie_data_tools/test/test_paths.py +137 -0
  40. oopsie_data_tools/test/test_python38_compat.py +79 -0
  41. oopsie_data_tools/test/test_record_step_purity.py +127 -0
  42. oopsie_data_tools/test/test_robot_setup.py +244 -0
  43. oopsie_data_tools/test/test_rollout_annotator.py +134 -0
  44. oopsie_data_tools/test/test_rotation_utils.py +126 -0
  45. oopsie_data_tools/test/test_validate.py +494 -0
  46. oopsie_data_tools/utils/__init__.py +1 -0
  47. oopsie_data_tools/utils/claude_skill.py +96 -0
  48. oopsie_data_tools/utils/contributor_config.py +91 -0
  49. oopsie_data_tools/utils/conversion_utils.py +220 -0
  50. oopsie_data_tools/utils/h5.py +60 -0
  51. oopsie_data_tools/utils/h5_inspect.py +167 -0
  52. oopsie_data_tools/utils/hf_limits.py +22 -0
  53. oopsie_data_tools/utils/hf_upload.py +235 -0
  54. oopsie_data_tools/utils/log.py +35 -0
  55. oopsie_data_tools/utils/migrate_taxonomy_v2.py +215 -0
  56. oopsie_data_tools/utils/paths.py +162 -0
  57. oopsie_data_tools/utils/restructure.py +402 -0
  58. oopsie_data_tools/utils/robot_profile/__init__.py +1 -0
  59. oopsie_data_tools/utils/robot_profile/robot_profile.py +240 -0
  60. oopsie_data_tools/utils/robot_profile/rotation_utils.py +119 -0
  61. oopsie_data_tools/utils/robot_profile/template.py +108 -0
  62. oopsie_data_tools/utils/validation/__init__.py +5 -0
  63. oopsie_data_tools/utils/validation/annotation_completeness.py +67 -0
  64. oopsie_data_tools/utils/validation/diversity.py +93 -0
  65. oopsie_data_tools/utils/validation/episode_data.py +54 -0
  66. oopsie_data_tools/utils/validation/episode_loader.py +201 -0
  67. oopsie_data_tools/utils/validation/episode_validator.py +315 -0
  68. oopsie_data_tools/utils/validation/errors.py +17 -0
  69. oopsie_data_tools/utils/validation/validation_utils.py +88 -0
  70. oopsie_data_tools-0.2.0.dist-info/METADATA +131 -0
  71. oopsie_data_tools-0.2.0.dist-info/RECORD +74 -0
  72. oopsie_data_tools-0.2.0.dist-info/WHEEL +4 -0
  73. oopsie_data_tools-0.2.0.dist-info/entry_points.txt +2 -0
  74. 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