eclise-clips 0.1.0__tar.gz
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.
- eclise_clips-0.1.0/PKG-INFO +10 -0
- eclise_clips-0.1.0/README.md +113 -0
- eclise_clips-0.1.0/pyproject.toml +28 -0
- eclise_clips-0.1.0/requirements.txt +17 -0
- eclise_clips-0.1.0/setup.cfg +4 -0
- eclise_clips-0.1.0/src/eclise/clips/__init__.py +97 -0
- eclise_clips-0.1.0/src/eclise/clips/codec.py +85 -0
- eclise_clips-0.1.0/src/eclise/clips/dataset.py +235 -0
- eclise_clips-0.1.0/src/eclise/clips/labels.py +198 -0
- eclise_clips-0.1.0/src/eclise/clips/readers/__init__.py +24 -0
- eclise_clips-0.1.0/src/eclise/clips/readers/phyworld_hdf5.py +489 -0
- eclise_clips-0.1.0/src/eclise/clips/schema.py +302 -0
- eclise_clips-0.1.0/src/eclise/clips/shards.py +487 -0
- eclise_clips-0.1.0/src/eclise_clips.egg-info/PKG-INFO +10 -0
- eclise_clips-0.1.0/src/eclise_clips.egg-info/SOURCES.txt +21 -0
- eclise_clips-0.1.0/src/eclise_clips.egg-info/dependency_links.txt +1 -0
- eclise_clips-0.1.0/src/eclise_clips.egg-info/requires.txt +7 -0
- eclise_clips-0.1.0/src/eclise_clips.egg-info/top_level.txt +1 -0
- eclise_clips-0.1.0/tests/test_dataset.py +204 -0
- eclise_clips-0.1.0/tests/test_labels.py +156 -0
- eclise_clips-0.1.0/tests/test_phyworld_reader.py +221 -0
- eclise_clips-0.1.0/tests/test_schema.py +84 -0
- eclise_clips-0.1.0/tests/test_shards.py +301 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eclise-clips
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Requires-Python: >=3.9
|
|
5
|
+
Requires-Dist: numpy>=1.24
|
|
6
|
+
Requires-Dist: h5py>=3.10
|
|
7
|
+
Requires-Dist: imageio[ffmpeg]>=2.36
|
|
8
|
+
Requires-Dist: av>=13.1
|
|
9
|
+
Provides-Extra: test
|
|
10
|
+
Requires-Dist: pytest~=8.4.0; extra == "test"
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
# eclise.clips
|
|
2
|
+
|
|
3
|
+
A shared on-disk contract for video clips carrying per-frame state labels.
|
|
4
|
+
|
|
5
|
+
This package sits between anything that **produces** clips and anything that
|
|
6
|
+
**consumes** them:
|
|
7
|
+
|
|
8
|
+
```
|
|
9
|
+
data/phyworld ──┐ ┌── video_world_models
|
|
10
|
+
├── eclise.clips shards ──┤
|
|
11
|
+
HF downloads ──┘ └── (future consumers)
|
|
12
|
+
```
|
|
13
|
+
|
|
14
|
+
Producers and consumers live in separate conda envs with incompatible pins and
|
|
15
|
+
never import each other. They agree on this package instead, which is what lets
|
|
16
|
+
generated data be consumed the same way real video will be.
|
|
17
|
+
|
|
18
|
+
## Reading
|
|
19
|
+
|
|
20
|
+
```python
|
|
21
|
+
from eclise.clips import ClipDataset
|
|
22
|
+
|
|
23
|
+
data = ClipDataset("data/phyworld/data/collision_30K")
|
|
24
|
+
item = data[0]
|
|
25
|
+
item["pixels"] # [3, T, H, W] float32 in [-1, 1]
|
|
26
|
+
item["positions"] # [T, n_objects, 2] world units, y up
|
|
27
|
+
item["velocity"] # [2] (vx, vy) px/frame, y down (image convention)
|
|
28
|
+
item["index"] # global clip index, survives the default collate
|
|
29
|
+
data.source_fps # 1 / frame_dt
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
`ClipDataset` is a plain torch `Dataset`; hand it to a `DataLoader` as usual.
|
|
33
|
+
`iter_clip_batches` yields `(clips [B,3,T,H,W], velocities [B,2])` for feeding a
|
|
34
|
+
frozen latent encoder.
|
|
35
|
+
|
|
36
|
+
Upstream phyworld's published HDF5 carries no metadata, so its family has to be
|
|
37
|
+
named (or inferred from the filename):
|
|
38
|
+
|
|
39
|
+
```python
|
|
40
|
+
from eclise.clips.readers import phyworld_hdf5
|
|
41
|
+
|
|
42
|
+
shards = phyworld_hdf5.open_shard_set("collision_30K.hdf5", family="collision")
|
|
43
|
+
data = ClipDataset(shards, velocity_object=0)
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
## Writing
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from eclise.clips import ShardMeta, ShardWriter, position_spec, params_spec
|
|
50
|
+
|
|
51
|
+
meta = ShardMeta(
|
|
52
|
+
family="uniform_motion",
|
|
53
|
+
num_clips=0, # overwritten with the realized count on close
|
|
54
|
+
clip_len=32,
|
|
55
|
+
image_size=(256, 256),
|
|
56
|
+
frame_dt=0.1, # seconds of simulated time between stored frames
|
|
57
|
+
world_scale=10.0,
|
|
58
|
+
labels={"positions": position_spec(), "params": params_spec(("r1", "v1"))},
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
with ShardWriter("shard_000.hdf5", meta) as w:
|
|
62
|
+
for frames, positions, params in scenes:
|
|
63
|
+
w.append(frames, positions=positions, params=params)
|
|
64
|
+
```
|
|
65
|
+
|
|
66
|
+
A shard is one HDF5 file; a dataset is a directory of them. `ShardSet` presents a
|
|
67
|
+
directory as one flat ordered clip sequence, so nothing needs a merge pass.
|
|
68
|
+
|
|
69
|
+
## Design notes
|
|
70
|
+
|
|
71
|
+
**Storage.** Clips are mp4 bytestreams in a variable-length `uint8` dataset, one
|
|
72
|
+
row per clip. A dense `uint8` array of 30k 32-frame 256px clips would be ~190GB;
|
|
73
|
+
mp4 brings that to a few GB. Inherited from upstream deliberately, so its
|
|
74
|
+
published files stay readable. Encoding is lossy — consumers see H.264
|
|
75
|
+
artifacts, which is why state-recovery checks run after a decode round-trip.
|
|
76
|
+
|
|
77
|
+
**`frame_dt`, not `fps`.** An mp4 header's frame rate is a playback preference.
|
|
78
|
+
For simulation output the number that matters is the simulated time between
|
|
79
|
+
stored frames, and upstream never records it: it writes clips with
|
|
80
|
+
`iio.imwrite(..., extension='.mp4')` and no `fps` at all, while the real spacing
|
|
81
|
+
is `timestep * stride = 0.01 * 10 = 0.1s`, set in code the reader never sees. So
|
|
82
|
+
`frame_dt` is required and authoritative, and `source_fps` is derived from it.
|
|
83
|
+
There is no `fps` field, because two rates can disagree.
|
|
84
|
+
|
|
85
|
+
**Labels are self-describing.** `[N, T, 2, 2]` could be `(clip, frame, ball, xy)`
|
|
86
|
+
in world units, pixels, or normalized coordinates — differing by factors that
|
|
87
|
+
silently rescale any derived velocity. Every label array carries a `LabelSpec`
|
|
88
|
+
recording axis names, units, and whether its y axis points up.
|
|
89
|
+
|
|
90
|
+
**The y-axis flip.** phyworld's stored positions have y increasing *upward* while
|
|
91
|
+
image rows increase downward: its generators render from normalized positions and
|
|
92
|
+
then reverse the row axis. `positions_to_pixels` and `velocity_from_state` read
|
|
93
|
+
`y_axis_up` off the spec and apply the flip, so no caller has to remember.
|
|
94
|
+
|
|
95
|
+
## Layout
|
|
96
|
+
|
|
97
|
+
| Module | Role |
|
|
98
|
+
|---|---|
|
|
99
|
+
| `schema.py` | `ShardMeta`, `LabelSpec`, required metadata, validation |
|
|
100
|
+
| `codec.py` | in-memory mp4 encode/decode |
|
|
101
|
+
| `shards.py` | `ShardWriter`, `ShardReader`, `ShardSet` |
|
|
102
|
+
| `labels.py` | world→pixel conversion, velocity, collision timing |
|
|
103
|
+
| `dataset.py` | `ClipDataset` (the only module needing torch) |
|
|
104
|
+
| `readers/` | adapters for layouts we don't control |
|
|
105
|
+
|
|
106
|
+
`schema`, `codec`, `shards`, `readers`, and `labels` are pure numpy, so a
|
|
107
|
+
generator env need not install torch.
|
|
108
|
+
|
|
109
|
+
## Tests
|
|
110
|
+
|
|
111
|
+
```bash
|
|
112
|
+
cd data/clips && python -m pytest
|
|
113
|
+
```
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eclise-clips"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
# 3.9, not 3.10: a producer sim may be pinned to an old Python by its simulator.
|
|
9
|
+
# data/phyworld's PHYRE-backed family runs in a 3.9 env because PHYRE's only
|
|
10
|
+
# prebuilt wheel is cp39 (see that project's README), and it must be able to
|
|
11
|
+
# write shards through this package's ShardWriter rather than a second
|
|
12
|
+
# implementation of the contract.
|
|
13
|
+
requires-python = ">=3.9"
|
|
14
|
+
dynamic = ["dependencies"]
|
|
15
|
+
|
|
16
|
+
[project.optional-dependencies]
|
|
17
|
+
test = ["pytest~=8.4.0"]
|
|
18
|
+
|
|
19
|
+
[tool.setuptools.packages.find]
|
|
20
|
+
where = ["src"]
|
|
21
|
+
include = ["eclise.clips*"]
|
|
22
|
+
|
|
23
|
+
[tool.setuptools.dynamic]
|
|
24
|
+
dependencies = {file = ["requirements.txt"]}
|
|
25
|
+
|
|
26
|
+
[tool.pytest.ini_options]
|
|
27
|
+
testpaths = ["tests"]
|
|
28
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# Runtime dependencies for eclise.clips.
|
|
2
|
+
#
|
|
3
|
+
# The on-disk clip contract (HDF5 shards holding mp4 bytestreams) plus the torch
|
|
4
|
+
# Dataset that decodes them. Deliberately light: a *producer* (a sim) and a
|
|
5
|
+
# *consumer* (a training project) install this into otherwise-incompatible envs,
|
|
6
|
+
# so anything heavier than these belongs in the consuming project.
|
|
7
|
+
|
|
8
|
+
numpy>=1.24
|
|
9
|
+
h5py>=3.10
|
|
10
|
+
# mp4 decode/encode of in-memory bytestreams. imageio.v3 does the byte-level
|
|
11
|
+
# imread/imwrite; av (PyAV) is the ffmpeg binding it dispatches to.
|
|
12
|
+
imageio[ffmpeg]>=2.36
|
|
13
|
+
av>=13.1
|
|
14
|
+
|
|
15
|
+
# torch is an *optional* dependency: schema/shards/readers are pure numpy, and
|
|
16
|
+
# only `eclise.clips.dataset` needs torch. Consumers all have their own pinned
|
|
17
|
+
# torch, so pinning one here would fight them. See dataset.py's import guard.
|
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
"""A shared on-disk contract for video clips with per-frame state labels.
|
|
2
|
+
|
|
3
|
+
Sits between anything that *produces* clips (a simulator, a download of a
|
|
4
|
+
published dataset, eventually a real-video ingest) and anything that *consumes*
|
|
5
|
+
them (a training project). Producers and consumers live in separate conda envs
|
|
6
|
+
and never import each other; they agree on this package instead.
|
|
7
|
+
|
|
8
|
+
from eclise.clips import ClipDataset
|
|
9
|
+
|
|
10
|
+
data = ClipDataset("data/phyworld/data/collision_30K")
|
|
11
|
+
item = data[0]
|
|
12
|
+
item["pixels"] # [3, T, H, W] float32 in [-1, 1]
|
|
13
|
+
item["positions"] # [T, n_objects, 2] world units, y up
|
|
14
|
+
item["velocity"] # [2] px/frame, y down (image convention)
|
|
15
|
+
data.source_fps # 1 / frame_dt -- authoritative, not the mp4 header
|
|
16
|
+
|
|
17
|
+
Upstream phyworld's published HDF5 carries no metadata, so it needs its family
|
|
18
|
+
named:
|
|
19
|
+
|
|
20
|
+
from eclise.clips.readers import phyworld_hdf5
|
|
21
|
+
|
|
22
|
+
shards = phyworld_hdf5.open_shard_set("collision_30K.hdf5", family="collision")
|
|
23
|
+
data = ClipDataset(shards, velocity_object=0)
|
|
24
|
+
|
|
25
|
+
Writing is the mirror image: declare a
|
|
26
|
+
:class:`~eclise.clips.schema.ShardMeta`, then append clips.
|
|
27
|
+
|
|
28
|
+
from eclise.clips import ShardMeta, ShardWriter, position_spec
|
|
29
|
+
|
|
30
|
+
meta = ShardMeta(
|
|
31
|
+
family="uniform_motion", num_clips=0, clip_len=32,
|
|
32
|
+
image_size=(256, 256), frame_dt=0.1, world_scale=10.0,
|
|
33
|
+
labels={"positions": position_spec()},
|
|
34
|
+
)
|
|
35
|
+
with ShardWriter("shard_000.hdf5", meta) as w:
|
|
36
|
+
w.append(frames, positions=positions)
|
|
37
|
+
|
|
38
|
+
Torch is needed only for :class:`~eclise.clips.dataset.ClipDataset`; the schema,
|
|
39
|
+
shard, and reader layers are pure numpy.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
from __future__ import annotations
|
|
43
|
+
|
|
44
|
+
from .codec import decode_mp4, encode_mp4
|
|
45
|
+
from .labels import (
|
|
46
|
+
collision_frame,
|
|
47
|
+
positions_to_pixels,
|
|
48
|
+
velocities_per_frame,
|
|
49
|
+
velocity_from_state,
|
|
50
|
+
)
|
|
51
|
+
from .schema import (
|
|
52
|
+
LABEL_GROUP,
|
|
53
|
+
META_ATTR,
|
|
54
|
+
SCHEMA_VERSION,
|
|
55
|
+
VIDEO_DATASET,
|
|
56
|
+
LabelSpec,
|
|
57
|
+
SchemaError,
|
|
58
|
+
ShardMeta,
|
|
59
|
+
object_attrs_spec,
|
|
60
|
+
params_spec,
|
|
61
|
+
position_spec,
|
|
62
|
+
velocity_field_spec,
|
|
63
|
+
)
|
|
64
|
+
from .shards import ShardReader, ShardSet, ShardWriter, write_shard
|
|
65
|
+
|
|
66
|
+
__all__ = [
|
|
67
|
+
"LABEL_GROUP",
|
|
68
|
+
"META_ATTR",
|
|
69
|
+
"SCHEMA_VERSION",
|
|
70
|
+
"VIDEO_DATASET",
|
|
71
|
+
"LabelSpec",
|
|
72
|
+
"SchemaError",
|
|
73
|
+
"ShardMeta",
|
|
74
|
+
"ShardReader",
|
|
75
|
+
"ShardSet",
|
|
76
|
+
"ShardWriter",
|
|
77
|
+
"collision_frame",
|
|
78
|
+
"decode_mp4",
|
|
79
|
+
"encode_mp4",
|
|
80
|
+
"object_attrs_spec",
|
|
81
|
+
"params_spec",
|
|
82
|
+
"position_spec",
|
|
83
|
+
"positions_to_pixels",
|
|
84
|
+
"velocities_per_frame",
|
|
85
|
+
"velocity_field_spec",
|
|
86
|
+
"velocity_from_state",
|
|
87
|
+
"write_shard",
|
|
88
|
+
]
|
|
89
|
+
|
|
90
|
+
|
|
91
|
+
def __getattr__(name: str):
|
|
92
|
+
"""Expose :class:`~eclise.clips.dataset.ClipDataset` without importing torch eagerly."""
|
|
93
|
+
if name in ("ClipDataset", "iter_clip_batches", "PIXEL_RANGES"):
|
|
94
|
+
from . import dataset
|
|
95
|
+
|
|
96
|
+
return getattr(dataset, name)
|
|
97
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""In-memory mp4 encode/decode for clip bytestreams.
|
|
2
|
+
|
|
3
|
+
Upstream phyworld carries a copy of this logic in every generator script (four
|
|
4
|
+
copies of ``convert_frames_to_mp4_bytestream_wo_disk``, plus disk-roundtripping
|
|
5
|
+
variants that were left behind after the in-memory version replaced them). This
|
|
6
|
+
is the one home for it.
|
|
7
|
+
|
|
8
|
+
Encoding is lossy. H.264 on synthetic content -- flat backgrounds with a few
|
|
9
|
+
hard-edged coloured circles -- is close to visually lossless at sane bitrates,
|
|
10
|
+
but "close" is not "exact", and every consumer of a decoded clip is looking at
|
|
11
|
+
compression artifacts. That is a property of the storage format we inherit, not
|
|
12
|
+
something this module can fix; it is why
|
|
13
|
+
``data/phyworld/scripts/verify_dataset.py`` measures state recovery *after* a
|
|
14
|
+
decode round-trip rather than trusting the pre-encode frames.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from io import BytesIO
|
|
20
|
+
|
|
21
|
+
import numpy as np
|
|
22
|
+
|
|
23
|
+
#: Encoder quality knob. imageio/ffmpeg's default CRF is 23, visibly soft on
|
|
24
|
+
#: hard synthetic edges; 18 is near-transparent for this content at a modest
|
|
25
|
+
#: size increase. Applies only to clips this package writes.
|
|
26
|
+
DEFAULT_CRF = 18
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def encode_mp4(frames: np.ndarray, *, crf: int = DEFAULT_CRF, fps: float = 24.0) -> np.ndarray:
|
|
30
|
+
"""Encode ``[T, H, W, 3]`` uint8 frames to an mp4 bytestream as ``[n] uint8``.
|
|
31
|
+
|
|
32
|
+
``fps`` lands in the container header only. It is *not* the physics frame
|
|
33
|
+
rate -- that is :attr:`eclise.clips.schema.ShardMeta.frame_dt`, recorded
|
|
34
|
+
separately. A value is passed anyway so the header is at least a valid,
|
|
35
|
+
deliberate number rather than an encoder default (upstream passes nothing,
|
|
36
|
+
which is how its stored clips ended up with a meaningless rate).
|
|
37
|
+
|
|
38
|
+
H.264 requires even frame dimensions; odd ones are rejected here rather than
|
|
39
|
+
silently rescaled, since a resized clip would invalidate every pixel-unit
|
|
40
|
+
label in the shard.
|
|
41
|
+
"""
|
|
42
|
+
import imageio.v3 as iio
|
|
43
|
+
|
|
44
|
+
arr = np.asarray(frames)
|
|
45
|
+
if arr.ndim != 4 or arr.shape[-1] != 3:
|
|
46
|
+
raise ValueError(f"expected frames [T, H, W, 3], got {arr.shape}")
|
|
47
|
+
if arr.dtype != np.uint8:
|
|
48
|
+
raise ValueError(f"expected uint8 frames, got {arr.dtype}")
|
|
49
|
+
h, w = arr.shape[1:3]
|
|
50
|
+
if h % 2 or w % 2:
|
|
51
|
+
raise ValueError(
|
|
52
|
+
f"H.264 needs even frame dimensions; got {h}x{w}. Render at an even "
|
|
53
|
+
"size rather than resizing here, which would invalidate pixel labels."
|
|
54
|
+
)
|
|
55
|
+
with BytesIO() as buffer:
|
|
56
|
+
iio.imwrite(
|
|
57
|
+
buffer,
|
|
58
|
+
arr,
|
|
59
|
+
extension=".mp4",
|
|
60
|
+
fps=fps,
|
|
61
|
+
codec="libx264",
|
|
62
|
+
# The pixel format goes through imageio's own keyword, not
|
|
63
|
+
# output_params: the ffmpeg plugin always emits a -pix_fmt of its own,
|
|
64
|
+
# so passing a second one makes ffmpeg warn once per encoded clip --
|
|
65
|
+
# millions of stderr lines on a full generation run, for a flag that
|
|
66
|
+
# was already set to the same value.
|
|
67
|
+
pixelformat="yuv420p",
|
|
68
|
+
output_params=["-crf", str(crf)],
|
|
69
|
+
)
|
|
70
|
+
payload = buffer.getvalue()
|
|
71
|
+
return np.frombuffer(payload, dtype=np.uint8)
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def decode_mp4(stream: np.ndarray | bytes) -> np.ndarray:
|
|
75
|
+
"""Decode an mp4 bytestream to ``[T, H, W, 3]`` uint8 frames."""
|
|
76
|
+
import imageio.v3 as iio
|
|
77
|
+
|
|
78
|
+
payload = stream.tobytes() if isinstance(stream, np.ndarray) else stream
|
|
79
|
+
frames = iio.imread(payload, index=None, extension=".mp4")
|
|
80
|
+
arr = np.asarray(frames)
|
|
81
|
+
if arr.ndim == 3: # a single-frame clip decodes without the leading axis
|
|
82
|
+
arr = arr[None]
|
|
83
|
+
if arr.shape[-1] == 4: # some decoders hand back RGBA
|
|
84
|
+
arr = arr[..., :3]
|
|
85
|
+
return arr
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
"""Torch ``Dataset`` over clip shards.
|
|
2
|
+
|
|
3
|
+
This is the consumer-facing end of the contract. A training project points
|
|
4
|
+
:class:`ClipDataset` at a directory of shards -- generated by a sim, downloaded
|
|
5
|
+
from upstream, or (later) holding real video -- and gets pixel clips plus labels,
|
|
6
|
+
without knowing which.
|
|
7
|
+
|
|
8
|
+
``torch`` is imported lazily: :mod:`eclise.clips.schema`, :mod:`.shards`, and
|
|
9
|
+
:mod:`.readers` are pure numpy so a generator-side env need not install torch at
|
|
10
|
+
all, while consumers bring their own pinned version.
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from collections.abc import Sequence
|
|
16
|
+
from pathlib import Path
|
|
17
|
+
from typing import Any
|
|
18
|
+
|
|
19
|
+
import numpy as np
|
|
20
|
+
|
|
21
|
+
from .labels import velocity_from_state
|
|
22
|
+
from .schema import SchemaError, ShardMeta
|
|
23
|
+
from .shards import ShardSet
|
|
24
|
+
|
|
25
|
+
#: How decoded uint8 pixels are mapped to floats.
|
|
26
|
+
#:
|
|
27
|
+
#: - ``"tanh"``: ``[-1, 1]``. The default, because it is what latent video
|
|
28
|
+
#: encoders (Wan VAE and friends) consume, and what
|
|
29
|
+
#: ``video_world_models``' own procedural stimuli already produce.
|
|
30
|
+
#: - ``"unit"``: ``[0, 1]``.
|
|
31
|
+
#: - ``"uint8"``: untouched ``0..255``, dtype preserved.
|
|
32
|
+
PIXEL_RANGES = ("tanh", "unit", "uint8")
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def _to_pixels(frames: np.ndarray, pixel_range: str) -> Any:
|
|
36
|
+
import torch
|
|
37
|
+
|
|
38
|
+
if pixel_range not in PIXEL_RANGES:
|
|
39
|
+
raise ValueError(
|
|
40
|
+
f"pixel_range must be one of {PIXEL_RANGES}; got {pixel_range!r}"
|
|
41
|
+
)
|
|
42
|
+
arr = np.ascontiguousarray(frames)
|
|
43
|
+
tensor = torch.from_numpy(arr) # [T, H, W, 3] uint8
|
|
44
|
+
tensor = tensor.permute(3, 0, 1, 2) # -> [3, T, H, W]
|
|
45
|
+
if pixel_range == "uint8":
|
|
46
|
+
return tensor.contiguous()
|
|
47
|
+
tensor = tensor.to(torch.float32) / 255.0
|
|
48
|
+
if pixel_range == "tanh":
|
|
49
|
+
tensor = tensor * 2.0 - 1.0
|
|
50
|
+
return tensor.contiguous()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
class ClipDataset:
|
|
54
|
+
"""Clips from one or more shards, as ``[3, T, H, W]`` tensors plus labels.
|
|
55
|
+
|
|
56
|
+
Each item is a dict:
|
|
57
|
+
|
|
58
|
+
- ``"pixels"``: ``[3, T, H, W]``, float32 in the configured range (or uint8).
|
|
59
|
+
- ``"index"``: global clip index, so a batched metric can be bucketed by any
|
|
60
|
+
per-clip property after the fact. Labels that are strings or ragged cannot
|
|
61
|
+
survive the default collate; an index always can.
|
|
62
|
+
- ``"velocity"``: ``[2]`` ``(vx, vy)`` px/frame, present when the shard has
|
|
63
|
+
positions and the family has one object (see ``velocity_object``).
|
|
64
|
+
- one entry per stored label, e.g. ``"positions"`` ``[T, n_obj, 2]``,
|
|
65
|
+
``"params"`` ``[K]``.
|
|
66
|
+
|
|
67
|
+
``meta`` (family, ``frame_dt``, ``world_scale``, ...) is a dataset-level
|
|
68
|
+
attribute, not per item -- it is identical for every clip, and putting it in
|
|
69
|
+
the item would multiply it by the batch size through the collate.
|
|
70
|
+
|
|
71
|
+
Args:
|
|
72
|
+
source: a shard directory, a single shard file, a list of files, or an
|
|
73
|
+
already-built :class:`~eclise.clips.shards.ShardSet`.
|
|
74
|
+
pixel_range: see :data:`PIXEL_RANGES`.
|
|
75
|
+
labels: which stored labels to include; ``None`` includes all.
|
|
76
|
+
velocity: emit a ``"velocity"`` entry derived from positions.
|
|
77
|
+
velocity_object: which object's velocity to use for multi-object
|
|
78
|
+
families. Required there, since a mean over a head-on collision is
|
|
79
|
+
~0 (see :func:`eclise.clips.labels.velocity_from_state`).
|
|
80
|
+
opener: reader class for non-canonical shards, e.g.
|
|
81
|
+
``functools.partial(phyworld_hdf5.open_shard, family="collision")``.
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
def __init__(
|
|
85
|
+
self,
|
|
86
|
+
source: str | Path | Sequence[str | Path] | ShardSet,
|
|
87
|
+
*,
|
|
88
|
+
pixel_range: str = "tanh",
|
|
89
|
+
labels: Sequence[str] | None = None,
|
|
90
|
+
velocity: bool = True,
|
|
91
|
+
velocity_object: int | None = None,
|
|
92
|
+
opener: Any = None,
|
|
93
|
+
) -> None:
|
|
94
|
+
if pixel_range not in PIXEL_RANGES:
|
|
95
|
+
raise ValueError(
|
|
96
|
+
f"pixel_range must be one of {PIXEL_RANGES}; got {pixel_range!r}"
|
|
97
|
+
)
|
|
98
|
+
self.shards = _resolve_source(source, opener=opener)
|
|
99
|
+
self.pixel_range = pixel_range
|
|
100
|
+
self.meta: ShardMeta = self.shards.meta
|
|
101
|
+
|
|
102
|
+
available = set(self.shards.label_names())
|
|
103
|
+
if labels is None:
|
|
104
|
+
self.labels = tuple(sorted(available))
|
|
105
|
+
else:
|
|
106
|
+
missing = set(labels) - available
|
|
107
|
+
if missing:
|
|
108
|
+
raise SchemaError(
|
|
109
|
+
f"requested labels {sorted(missing)} are not in these shards; "
|
|
110
|
+
f"available: {sorted(available)}"
|
|
111
|
+
)
|
|
112
|
+
self.labels = tuple(labels)
|
|
113
|
+
|
|
114
|
+
self._velocity_object = velocity_object
|
|
115
|
+
self.emits_velocity = bool(velocity and "positions" in available)
|
|
116
|
+
if velocity and not self.emits_velocity:
|
|
117
|
+
raise SchemaError(
|
|
118
|
+
"velocity=True needs a 'positions' label, which these shards do "
|
|
119
|
+
f"not have (available: {sorted(available)}). Pass velocity=False, "
|
|
120
|
+
"or use a family that records positions."
|
|
121
|
+
)
|
|
122
|
+
if self.emits_velocity:
|
|
123
|
+
num_objects = int(self.meta.extra.get("num_objects", 1) or 1)
|
|
124
|
+
if num_objects > 1 and velocity_object is None:
|
|
125
|
+
raise SchemaError(
|
|
126
|
+
f"family {self.meta.family!r} has {num_objects} objects, so a "
|
|
127
|
+
"single per-clip velocity is ambiguous. Pass velocity_object=, "
|
|
128
|
+
"or velocity=False and derive from the 'positions' label."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def source_fps(self) -> float:
|
|
133
|
+
"""``1 / frame_dt``, for a latent encoder's FPS-aware path."""
|
|
134
|
+
return self.meta.source_fps
|
|
135
|
+
|
|
136
|
+
def manifest_key(self, *, length: int = 16) -> str:
|
|
137
|
+
"""Stable hash of the underlying shard set, for addressing derived caches."""
|
|
138
|
+
return self.shards.manifest_key(length=length)
|
|
139
|
+
|
|
140
|
+
def manifest(self) -> dict[str, Any]:
|
|
141
|
+
return self.shards.manifest()
|
|
142
|
+
|
|
143
|
+
def __len__(self) -> int:
|
|
144
|
+
return len(self.shards)
|
|
145
|
+
|
|
146
|
+
def __getitem__(self, index: int) -> dict[str, Any]:
|
|
147
|
+
import torch
|
|
148
|
+
|
|
149
|
+
item: dict[str, Any] = {
|
|
150
|
+
"pixels": _to_pixels(self.shards.frames(index), self.pixel_range),
|
|
151
|
+
"index": index,
|
|
152
|
+
}
|
|
153
|
+
for name in self.labels:
|
|
154
|
+
value = np.asarray(self.shards.label(name, index))
|
|
155
|
+
item[name] = torch.from_numpy(np.ascontiguousarray(value)).to(torch.float32)
|
|
156
|
+
if self.emits_velocity:
|
|
157
|
+
positions = np.asarray(self.shards.label("positions", index))
|
|
158
|
+
item["velocity"] = torch.from_numpy(
|
|
159
|
+
velocity_from_state(
|
|
160
|
+
positions,
|
|
161
|
+
self.shards.spec("positions"),
|
|
162
|
+
image_size=self.meta.image_size,
|
|
163
|
+
world_scale=self.meta.world_scale,
|
|
164
|
+
object_index=self._velocity_object,
|
|
165
|
+
)
|
|
166
|
+
)
|
|
167
|
+
return item
|
|
168
|
+
|
|
169
|
+
def close(self) -> None:
|
|
170
|
+
self.shards.close()
|
|
171
|
+
|
|
172
|
+
def __enter__(self) -> ClipDataset:
|
|
173
|
+
return self
|
|
174
|
+
|
|
175
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
176
|
+
self.close()
|
|
177
|
+
|
|
178
|
+
def __repr__(self) -> str:
|
|
179
|
+
return (
|
|
180
|
+
f"ClipDataset(family={self.meta.family!r}, split={self.meta.split!r}, "
|
|
181
|
+
f"clips={len(self)}, clip_len={self.meta.clip_len}, "
|
|
182
|
+
f"image_size={self.meta.image_size}, frame_dt={self.meta.frame_dt}, "
|
|
183
|
+
f"pixel_range={self.pixel_range!r})"
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def _resolve_source(
|
|
188
|
+
source: str | Path | Sequence[str | Path] | ShardSet, *, opener: Any
|
|
189
|
+
) -> ShardSet:
|
|
190
|
+
if isinstance(source, ShardSet):
|
|
191
|
+
return source
|
|
192
|
+
if isinstance(source, (str, Path)):
|
|
193
|
+
path = Path(source)
|
|
194
|
+
if path.is_dir():
|
|
195
|
+
return ShardSet.from_dir(path, opener=opener)
|
|
196
|
+
return ShardSet([path], opener=opener)
|
|
197
|
+
return ShardSet(list(source), opener=opener)
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def iter_clip_batches(
|
|
201
|
+
dataset: ClipDataset,
|
|
202
|
+
*,
|
|
203
|
+
batch_size: int = 8,
|
|
204
|
+
velocity_fallback: bool = True,
|
|
205
|
+
) -> Any:
|
|
206
|
+
"""Yield ``(clips [B, 3, T, H, W], velocities [B, 2])`` batches.
|
|
207
|
+
|
|
208
|
+
The shape a frozen latent encoder wants. Kept here rather than in the
|
|
209
|
+
consumer so that "render a clip grid" and "read clips off disk" present the
|
|
210
|
+
same interface to an encode loop.
|
|
211
|
+
|
|
212
|
+
With ``velocity_fallback`` and no velocity available, zeros are emitted --
|
|
213
|
+
appropriate when velocity is only a diagnostic label, not a training target.
|
|
214
|
+
"""
|
|
215
|
+
import torch
|
|
216
|
+
|
|
217
|
+
clips: list[Any] = []
|
|
218
|
+
vels: list[Any] = []
|
|
219
|
+
for index in range(len(dataset)):
|
|
220
|
+
item = dataset[index]
|
|
221
|
+
clips.append(item["pixels"])
|
|
222
|
+
if "velocity" in item:
|
|
223
|
+
vels.append(item["velocity"])
|
|
224
|
+
elif velocity_fallback:
|
|
225
|
+
vels.append(torch.zeros(2, dtype=torch.float32))
|
|
226
|
+
else:
|
|
227
|
+
raise SchemaError(
|
|
228
|
+
"dataset emits no velocity; pass velocity_fallback=True to get "
|
|
229
|
+
"zeros, or construct the ClipDataset with velocity=True"
|
|
230
|
+
)
|
|
231
|
+
if len(clips) == batch_size:
|
|
232
|
+
yield torch.stack(clips), torch.stack(vels)
|
|
233
|
+
clips, vels = [], []
|
|
234
|
+
if clips:
|
|
235
|
+
yield torch.stack(clips), torch.stack(vels)
|