eclise 0.1.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.
- eclise/clips/__init__.py +97 -0
- eclise/clips/codec.py +85 -0
- eclise/clips/dataset.py +237 -0
- eclise/clips/labels.py +198 -0
- eclise/clips/readers/__init__.py +24 -0
- eclise/clips/readers/phyworld_hdf5.py +489 -0
- eclise/clips/schema.py +302 -0
- eclise/clips/shards.py +487 -0
- eclise/datasets/__init__.py +17 -0
- eclise/datasets/_resources/imagenet_classes.json +5250 -0
- eclise/datasets/imagenet.py +131 -0
- eclise/datasets/labels.py +65 -0
- eclise/launch/__init__.py +24 -0
- eclise/launch/__main__.py +125 -0
- eclise/launch/_version.py +3 -0
- eclise/launch/errors.py +5 -0
- eclise/launch/launcher.py +216 -0
- eclise/launch/pixi.py +181 -0
- eclise/launch/registry.py +69 -0
- eclise/launch/root.py +153 -0
- eclise/libero/__init__.py +27 -0
- eclise/libero/evaluation/__init__.py +11 -0
- eclise/libero/evaluation/config.py +63 -0
- eclise/libero/evaluation/metrics.py +36 -0
- eclise/libero/evaluation/runner.py +174 -0
- eclise/libero/harness/__init__.py +18 -0
- eclise/libero/harness/env.py +88 -0
- eclise/libero/harness/libero_env.py +107 -0
- eclise/libero/harness/rollout.py +14 -0
- eclise/libero/observation.py +32 -0
- eclise/libero/serve.py +230 -0
- eclise/octo/__init__.py +14 -0
- eclise/octo/obs_adapter.py +190 -0
- eclise/octo/policy.py +133 -0
- eclise/octo/serve.py +76 -0
- eclise/octo/training/__init__.py +39 -0
- eclise/octo/training/config.py +76 -0
- eclise/octo/training/data.py +507 -0
- eclise/octo/training/step.py +47 -0
- eclise/octo/training/train_state.py +102 -0
- eclise/octo/training/transforms.py +53 -0
- eclise/openvid/__init__.py +58 -0
- eclise/openvid/config.py +183 -0
- eclise/openvid/hub.py +178 -0
- eclise/openvid/ingest.py +526 -0
- eclise/openvid/metadata.py +342 -0
- eclise/openvid/split.py +33 -0
- eclise/openvid/video.py +291 -0
- eclise/phyworld/__init__.py +66 -0
- eclise/phyworld/analysis/__init__.py +41 -0
- eclise/phyworld/analysis/metrics.py +201 -0
- eclise/phyworld/analysis/state_recovery.py +275 -0
- eclise/phyworld/analysis/verify.py +213 -0
- eclise/phyworld/combinatorial_driver.py +302 -0
- eclise/phyworld/config.py +353 -0
- eclise/phyworld/generate.py +447 -0
- eclise/phyworld/grids.py +368 -0
- eclise/phyworld/render.py +477 -0
- eclise/phyworld/scenes/__init__.py +51 -0
- eclise/phyworld/scenes/base.py +177 -0
- eclise/phyworld/scenes/collision.py +192 -0
- eclise/phyworld/scenes/combinatorial.py +386 -0
- eclise/phyworld/scenes/parabola.py +92 -0
- eclise/phyworld/scenes/phyre_compat.py +400 -0
- eclise/phyworld/scenes/uniform_motion.py +207 -0
- eclise/pi0/__init__.py +17 -0
- eclise/smolvla/__init__.py +1 -0
- eclise/smolvla/obs_adapter.py +89 -0
- eclise/smolvla/policy.py +118 -0
- eclise/smolvla/serve.py +67 -0
- eclise/utils/__init__.py +103 -0
- eclise/utils/are.py +409 -0
- eclise/utils/arrays.py +93 -0
- eclise/utils/dicts.py +85 -0
- eclise/utils/ensemble.py +137 -0
- eclise/utils/fast_conv_scan.py +171 -0
- eclise/utils/io.py +40 -0
- eclise/utils/labeled_array.py +213 -0
- eclise/utils/linalg.py +285 -0
- eclise/utils/memory.py +61 -0
- eclise/utils/modules.py +243 -0
- eclise/utils/ode.py +77 -0
- eclise/utils/optim.py +34 -0
- eclise/utils/plotting.py +50 -0
- eclise/utils/recursive.py +50 -0
- eclise/utils/scan.py +67 -0
- eclise/utils/settings.py +137 -0
- eclise/utils/timing.py +96 -0
- eclise/utils/types.py +19 -0
- eclise/vla/__init__.py +74 -0
- eclise/vla/_ws.py +23 -0
- eclise/vla/env.py +70 -0
- eclise/vla/env_client.py +104 -0
- eclise/vla/env_server.py +134 -0
- eclise/vla/msgpack_numpy.py +58 -0
- eclise/vla/observation.py +78 -0
- eclise/vla/policy.py +60 -0
- eclise/vla/policy_client.py +95 -0
- eclise/vla/rollout.py +181 -0
- eclise/vla/serve_dummy.py +30 -0
- eclise/vla/server.py +93 -0
- eclise-0.1.0.dist-info/METADATA +183 -0
- eclise-0.1.0.dist-info/RECORD +106 -0
- eclise-0.1.0.dist-info/WHEEL +5 -0
- eclise-0.1.0.dist-info/licenses/LICENSE +21 -0
- eclise-0.1.0.dist-info/top_level.txt +1 -0
eclise/clips/__init__.py
ADDED
|
@@ -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}")
|
eclise/clips/codec.py
ADDED
|
@@ -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_shards.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
|
eclise/clips/dataset.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
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 when that
|
|
77
|
+
label exists. Shards without positions (real video) omit it rather
|
|
78
|
+
than erroring, so a consumer can point ``ClipDataset`` at either
|
|
79
|
+
family without a flag.
|
|
80
|
+
velocity_object: which object's velocity to use for multi-object
|
|
81
|
+
families. Required there, since a mean over a head-on collision is
|
|
82
|
+
~0 (see :func:`eclise.clips.labels.velocity_from_state`).
|
|
83
|
+
opener: reader class for non-canonical shards, e.g.
|
|
84
|
+
``functools.partial(phyworld_hdf5.open_shard, family="collision")``.
|
|
85
|
+
"""
|
|
86
|
+
|
|
87
|
+
def __init__(
|
|
88
|
+
self,
|
|
89
|
+
source: str | Path | Sequence[str | Path] | ShardSet,
|
|
90
|
+
*,
|
|
91
|
+
pixel_range: str = "tanh",
|
|
92
|
+
labels: Sequence[str] | None = None,
|
|
93
|
+
velocity: bool = True,
|
|
94
|
+
velocity_object: int | None = None,
|
|
95
|
+
opener: Any = None,
|
|
96
|
+
) -> None:
|
|
97
|
+
if pixel_range not in PIXEL_RANGES:
|
|
98
|
+
raise ValueError(
|
|
99
|
+
f"pixel_range must be one of {PIXEL_RANGES}; got {pixel_range!r}"
|
|
100
|
+
)
|
|
101
|
+
self.shards = _resolve_source(source, opener=opener)
|
|
102
|
+
self.pixel_range = pixel_range
|
|
103
|
+
self.meta: ShardMeta = self.shards.meta
|
|
104
|
+
|
|
105
|
+
available = set(self.shards.label_names())
|
|
106
|
+
if labels is None:
|
|
107
|
+
self.labels = tuple(sorted(available))
|
|
108
|
+
else:
|
|
109
|
+
missing = set(labels) - available
|
|
110
|
+
if missing:
|
|
111
|
+
raise SchemaError(
|
|
112
|
+
f"requested labels {sorted(missing)} are not in these shards; "
|
|
113
|
+
f"available: {sorted(available)}"
|
|
114
|
+
)
|
|
115
|
+
self.labels = tuple(labels)
|
|
116
|
+
|
|
117
|
+
self._velocity_object = velocity_object
|
|
118
|
+
# Real-video shards (OpenVid ingest) have no positions. ``velocity=True``
|
|
119
|
+
# then simply omits the derived entry rather than requiring every caller
|
|
120
|
+
# to pass velocity=False -- ``open_clip_dataset`` in video_world_models
|
|
121
|
+
# stays unchanged. Multi-object families that *do* record positions
|
|
122
|
+
# still require ``velocity_object`` below, because averaging is ambiguous.
|
|
123
|
+
self.emits_velocity = bool(velocity and "positions" in available)
|
|
124
|
+
if self.emits_velocity:
|
|
125
|
+
num_objects = int(self.meta.extra.get("num_objects", 1) or 1)
|
|
126
|
+
if num_objects > 1 and velocity_object is None:
|
|
127
|
+
raise SchemaError(
|
|
128
|
+
f"family {self.meta.family!r} has {num_objects} objects, so a "
|
|
129
|
+
"single per-clip velocity is ambiguous. Pass velocity_object=, "
|
|
130
|
+
"or velocity=False and derive from the 'positions' label."
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
@property
|
|
134
|
+
def source_fps(self) -> float:
|
|
135
|
+
"""``1 / frame_dt``, for a latent encoder's FPS-aware path."""
|
|
136
|
+
return self.meta.source_fps
|
|
137
|
+
|
|
138
|
+
def manifest_key(self, *, length: int = 16) -> str:
|
|
139
|
+
"""Stable hash of the underlying shard set, for addressing derived caches."""
|
|
140
|
+
return self.shards.manifest_key(length=length)
|
|
141
|
+
|
|
142
|
+
def manifest(self) -> dict[str, Any]:
|
|
143
|
+
return self.shards.manifest()
|
|
144
|
+
|
|
145
|
+
def __len__(self) -> int:
|
|
146
|
+
return len(self.shards)
|
|
147
|
+
|
|
148
|
+
def __getitem__(self, index: int) -> dict[str, Any]:
|
|
149
|
+
import torch
|
|
150
|
+
|
|
151
|
+
item: dict[str, Any] = {
|
|
152
|
+
"pixels": _to_pixels(self.shards.frames(index), self.pixel_range),
|
|
153
|
+
"index": index,
|
|
154
|
+
}
|
|
155
|
+
for name in self.labels:
|
|
156
|
+
value = np.asarray(self.shards.label(name, index))
|
|
157
|
+
item[name] = torch.from_numpy(np.ascontiguousarray(value)).to(torch.float32)
|
|
158
|
+
if self.emits_velocity:
|
|
159
|
+
positions = np.asarray(self.shards.label("positions", index))
|
|
160
|
+
item["velocity"] = torch.from_numpy(
|
|
161
|
+
velocity_from_state(
|
|
162
|
+
positions,
|
|
163
|
+
self.shards.spec("positions"),
|
|
164
|
+
image_size=self.meta.image_size,
|
|
165
|
+
world_scale=self.meta.world_scale,
|
|
166
|
+
object_index=self._velocity_object,
|
|
167
|
+
)
|
|
168
|
+
)
|
|
169
|
+
return item
|
|
170
|
+
|
|
171
|
+
def close(self) -> None:
|
|
172
|
+
self.shards.close()
|
|
173
|
+
|
|
174
|
+
def __enter__(self) -> ClipDataset:
|
|
175
|
+
return self
|
|
176
|
+
|
|
177
|
+
def __exit__(self, exc_type, exc, tb) -> None:
|
|
178
|
+
self.close()
|
|
179
|
+
|
|
180
|
+
def __repr__(self) -> str:
|
|
181
|
+
return (
|
|
182
|
+
f"ClipDataset(family={self.meta.family!r}, split={self.meta.split!r}, "
|
|
183
|
+
f"clips={len(self)}, clip_len={self.meta.clip_len}, "
|
|
184
|
+
f"image_size={self.meta.image_size}, frame_dt={self.meta.frame_dt}, "
|
|
185
|
+
f"pixel_range={self.pixel_range!r})"
|
|
186
|
+
)
|
|
187
|
+
|
|
188
|
+
|
|
189
|
+
def _resolve_source(
|
|
190
|
+
source: str | Path | Sequence[str | Path] | ShardSet, *, opener: Any
|
|
191
|
+
) -> ShardSet:
|
|
192
|
+
if isinstance(source, ShardSet):
|
|
193
|
+
return source
|
|
194
|
+
if isinstance(source, (str, Path)):
|
|
195
|
+
path = Path(source)
|
|
196
|
+
if path.is_dir():
|
|
197
|
+
return ShardSet.from_dir(path, opener=opener)
|
|
198
|
+
return ShardSet([path], opener=opener)
|
|
199
|
+
return ShardSet(list(source), opener=opener)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def iter_clip_batches(
|
|
203
|
+
dataset: ClipDataset,
|
|
204
|
+
*,
|
|
205
|
+
batch_size: int = 8,
|
|
206
|
+
velocity_fallback: bool = True,
|
|
207
|
+
) -> Any:
|
|
208
|
+
"""Yield ``(clips [B, 3, T, H, W], velocities [B, 2])`` batches.
|
|
209
|
+
|
|
210
|
+
The shape a frozen latent encoder wants. Kept here rather than in the
|
|
211
|
+
consumer so that "render a clip grid" and "read clips off disk" present the
|
|
212
|
+
same interface to an encode loop.
|
|
213
|
+
|
|
214
|
+
With ``velocity_fallback`` and no velocity available, zeros are emitted --
|
|
215
|
+
appropriate when velocity is only a diagnostic label, not a training target.
|
|
216
|
+
"""
|
|
217
|
+
import torch
|
|
218
|
+
|
|
219
|
+
clips: list[Any] = []
|
|
220
|
+
vels: list[Any] = []
|
|
221
|
+
for index in range(len(dataset)):
|
|
222
|
+
item = dataset[index]
|
|
223
|
+
clips.append(item["pixels"])
|
|
224
|
+
if "velocity" in item:
|
|
225
|
+
vels.append(item["velocity"])
|
|
226
|
+
elif velocity_fallback:
|
|
227
|
+
vels.append(torch.zeros(2, dtype=torch.float32))
|
|
228
|
+
else:
|
|
229
|
+
raise SchemaError(
|
|
230
|
+
"dataset emits no velocity; pass velocity_fallback=True to get "
|
|
231
|
+
"zeros, or construct the ClipDataset with velocity=True"
|
|
232
|
+
)
|
|
233
|
+
if len(clips) == batch_size:
|
|
234
|
+
yield torch.stack(clips), torch.stack(vels)
|
|
235
|
+
clips, vels = [], []
|
|
236
|
+
if clips:
|
|
237
|
+
yield torch.stack(clips), torch.stack(vels)
|
eclise/clips/labels.py
ADDED
|
@@ -0,0 +1,198 @@
|
|
|
1
|
+
"""Deriving training targets from stored state labels.
|
|
2
|
+
|
|
3
|
+
Stored labels describe the *simulation*: object centres in world units, on a
|
|
4
|
+
y-up axis. Training code wants quantities in *pixel* terms on the image's y-down
|
|
5
|
+
axis. The conversion is three multiplications and a sign flip, and getting any
|
|
6
|
+
of them wrong produces plausible-looking numbers that are quietly rescaled or
|
|
7
|
+
mirrored -- so it lives here once, driven off the
|
|
8
|
+
:class:`~eclise.clips.schema.LabelSpec` rather than off a caller's assumption.
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import numpy as np
|
|
14
|
+
|
|
15
|
+
from .schema import LabelSpec, SchemaError
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def positions_to_pixels(
|
|
19
|
+
positions: np.ndarray,
|
|
20
|
+
spec: LabelSpec,
|
|
21
|
+
*,
|
|
22
|
+
image_size: tuple[int, int],
|
|
23
|
+
world_scale: float | None,
|
|
24
|
+
) -> np.ndarray:
|
|
25
|
+
"""Convert stored positions to pixel coordinates with rows increasing downward.
|
|
26
|
+
|
|
27
|
+
Args:
|
|
28
|
+
positions: ``[..., 2]`` in the units ``spec`` declares.
|
|
29
|
+
spec: the label's spec; supplies ``units`` and ``y_axis_up``.
|
|
30
|
+
image_size: ``(height, width)`` of the rendered frames.
|
|
31
|
+
world_scale: world extent, required when ``spec.units == "world"``.
|
|
32
|
+
|
|
33
|
+
Returns:
|
|
34
|
+
The same shape, in pixels, y measured downward from the top row.
|
|
35
|
+
"""
|
|
36
|
+
arr = np.asarray(positions, dtype=np.float64)
|
|
37
|
+
if arr.shape[-1] != 2:
|
|
38
|
+
raise SchemaError(f"expected a trailing xy axis, got shape {arr.shape}")
|
|
39
|
+
height, width = image_size
|
|
40
|
+
|
|
41
|
+
if spec.units == "world":
|
|
42
|
+
if not world_scale:
|
|
43
|
+
raise SchemaError(
|
|
44
|
+
f"label {spec.name!r} is in world units, so world_scale is "
|
|
45
|
+
"required to convert to pixels"
|
|
46
|
+
)
|
|
47
|
+
scale = np.array([width / world_scale, height / world_scale])
|
|
48
|
+
elif spec.units == "normalized":
|
|
49
|
+
scale = np.array([float(width), float(height)])
|
|
50
|
+
elif spec.units == "pixel":
|
|
51
|
+
scale = np.array([1.0, 1.0])
|
|
52
|
+
else:
|
|
53
|
+
raise SchemaError(
|
|
54
|
+
f"label {spec.name!r} has units {spec.units!r}, which has no defined "
|
|
55
|
+
"mapping to pixels; expected 'world', 'normalized', or 'pixel'"
|
|
56
|
+
)
|
|
57
|
+
|
|
58
|
+
out = arr * scale
|
|
59
|
+
if spec.y_axis_up:
|
|
60
|
+
# Rows increase downward, so a y measured upward from the bottom becomes
|
|
61
|
+
# (height - y). Rendering reverses the row axis after drawing, which is
|
|
62
|
+
# what puts the stored coordinates on the opposite convention.
|
|
63
|
+
out[..., 1] = height - out[..., 1]
|
|
64
|
+
return out
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
def velocity_from_state(
|
|
68
|
+
positions: np.ndarray,
|
|
69
|
+
spec: LabelSpec,
|
|
70
|
+
*,
|
|
71
|
+
image_size: tuple[int, int],
|
|
72
|
+
world_scale: float | None,
|
|
73
|
+
frame_dt: float | None = None,
|
|
74
|
+
per_second: bool = False,
|
|
75
|
+
object_index: int | None = None,
|
|
76
|
+
) -> np.ndarray:
|
|
77
|
+
"""Mean velocity over a clip, in pixels per frame (or per second).
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
positions: ``[frame, object, 2]`` for one clip, as
|
|
81
|
+
:meth:`~eclise.clips.shards.ShardReader.label` returns.
|
|
82
|
+
spec: the positions label's spec.
|
|
83
|
+
image_size: ``(height, width)``.
|
|
84
|
+
world_scale: world extent, for ``units == "world"``.
|
|
85
|
+
frame_dt: required only when ``per_second`` is set.
|
|
86
|
+
per_second: return px/s instead of px/frame.
|
|
87
|
+
object_index: velocity of one object; ``None`` averages over objects.
|
|
88
|
+
Averaging is only meaningful for single-object families -- for a
|
|
89
|
+
head-on collision the two balls move oppositely and the mean is near
|
|
90
|
+
zero, which is why this defaults to erroring on ambiguity rather than
|
|
91
|
+
silently averaging. Pass an index for multi-object families.
|
|
92
|
+
|
|
93
|
+
Returns:
|
|
94
|
+
``[2]`` ``(vx, vy)`` with y increasing downward, matching image rows.
|
|
95
|
+
|
|
96
|
+
The estimate is the total displacement divided by the number of steps, i.e.
|
|
97
|
+
the mean over the clip. For uniform motion that is exact. For a collision or
|
|
98
|
+
a parabola it is the *average*, not an instantaneous velocity, and is not a
|
|
99
|
+
meaningful summary; use :func:`velocities_per_frame` for those.
|
|
100
|
+
"""
|
|
101
|
+
arr = np.asarray(positions, dtype=np.float64)
|
|
102
|
+
if arr.ndim == 2: # [frame, xy] -- object axis elided
|
|
103
|
+
arr = arr[:, None, :]
|
|
104
|
+
if arr.ndim != 3:
|
|
105
|
+
raise SchemaError(
|
|
106
|
+
f"expected positions [frame, object, xy] for one clip, got {arr.shape}"
|
|
107
|
+
)
|
|
108
|
+
if arr.shape[0] < 2:
|
|
109
|
+
raise SchemaError("need at least 2 frames to estimate a velocity")
|
|
110
|
+
|
|
111
|
+
px = positions_to_pixels(
|
|
112
|
+
arr, spec, image_size=image_size, world_scale=world_scale
|
|
113
|
+
)
|
|
114
|
+
num_objects = px.shape[1]
|
|
115
|
+
if object_index is None:
|
|
116
|
+
if num_objects > 1:
|
|
117
|
+
raise SchemaError(
|
|
118
|
+
f"positions hold {num_objects} objects; a single velocity is "
|
|
119
|
+
"ambiguous (for a head-on collision the mean is ~0). Pass "
|
|
120
|
+
"object_index=, or use velocities_per_frame()."
|
|
121
|
+
)
|
|
122
|
+
object_index = 0
|
|
123
|
+
|
|
124
|
+
track = px[:, object_index, :]
|
|
125
|
+
steps = track.shape[0] - 1
|
|
126
|
+
velocity = (track[-1] - track[0]) / steps
|
|
127
|
+
if per_second:
|
|
128
|
+
if not frame_dt:
|
|
129
|
+
raise SchemaError("per_second=True requires frame_dt")
|
|
130
|
+
velocity = velocity / frame_dt
|
|
131
|
+
return velocity.astype(np.float32)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
def velocities_per_frame(
|
|
135
|
+
positions: np.ndarray,
|
|
136
|
+
spec: LabelSpec,
|
|
137
|
+
*,
|
|
138
|
+
image_size: tuple[int, int],
|
|
139
|
+
world_scale: float | None,
|
|
140
|
+
frame_dt: float | None = None,
|
|
141
|
+
per_second: bool = False,
|
|
142
|
+
) -> np.ndarray:
|
|
143
|
+
"""Per-step velocities, ``[frame - 1, object, 2]`` in px/frame (or px/s).
|
|
144
|
+
|
|
145
|
+
Finite differences between consecutive stored frames. This is what a
|
|
146
|
+
collision or parabola needs, where velocity changes within the clip.
|
|
147
|
+
"""
|
|
148
|
+
arr = np.asarray(positions, dtype=np.float64)
|
|
149
|
+
if arr.ndim == 2:
|
|
150
|
+
arr = arr[:, None, :]
|
|
151
|
+
px = positions_to_pixels(
|
|
152
|
+
arr, spec, image_size=image_size, world_scale=world_scale
|
|
153
|
+
)
|
|
154
|
+
diffs = np.diff(px, axis=0)
|
|
155
|
+
if per_second:
|
|
156
|
+
if not frame_dt:
|
|
157
|
+
raise SchemaError("per_second=True requires frame_dt")
|
|
158
|
+
diffs = diffs / frame_dt
|
|
159
|
+
return diffs.astype(np.float32)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def collision_frame(
|
|
163
|
+
positions: np.ndarray,
|
|
164
|
+
spec: LabelSpec,
|
|
165
|
+
*,
|
|
166
|
+
radii: np.ndarray | None = None,
|
|
167
|
+
tolerance: float = 1e-3,
|
|
168
|
+
) -> int | None:
|
|
169
|
+
"""Index of the first frame where two objects are in contact, if any.
|
|
170
|
+
|
|
171
|
+
Contact means centre distance <= sum of radii (plus ``tolerance``). Without
|
|
172
|
+
``radii``, falls back to the frame of maximum approach speed change, which is
|
|
173
|
+
where a discrete elastic bounce shows up. Returns ``None`` when the objects
|
|
174
|
+
never meet -- a legitimate outcome, since the generator's rejection sampling
|
|
175
|
+
does not guarantee a collision lands inside the recorded window.
|
|
176
|
+
|
|
177
|
+
Units are whatever ``positions`` and ``radii`` share; no conversion is done,
|
|
178
|
+
so pass both in world units or both in pixels.
|
|
179
|
+
"""
|
|
180
|
+
arr = np.asarray(positions, dtype=np.float64)
|
|
181
|
+
if arr.ndim != 3 or arr.shape[1] < 2:
|
|
182
|
+
raise SchemaError(
|
|
183
|
+
f"collision detection needs positions [frame, >=2 objects, xy], got "
|
|
184
|
+
f"{arr.shape}"
|
|
185
|
+
)
|
|
186
|
+
gap = np.linalg.norm(arr[:, 1, :] - arr[:, 0, :], axis=-1)
|
|
187
|
+
if radii is not None:
|
|
188
|
+
threshold = float(np.sum(np.asarray(radii, dtype=np.float64)[:2])) + tolerance
|
|
189
|
+
hits = np.flatnonzero(gap <= threshold)
|
|
190
|
+
return int(hits[0]) if hits.size else None
|
|
191
|
+
if gap.size < 3:
|
|
192
|
+
return None
|
|
193
|
+
# Approach rate flips sign at an elastic bounce; the largest jump in its
|
|
194
|
+
# derivative is the contact frame.
|
|
195
|
+
curvature = np.abs(np.diff(gap, n=2))
|
|
196
|
+
if not np.any(curvature > tolerance):
|
|
197
|
+
return None
|
|
198
|
+
return int(np.argmax(curvature)) + 1
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
"""Format adapters.
|
|
2
|
+
|
|
3
|
+
A reader turns some on-disk layout into the :class:`~eclise.clips.shards.ShardReader`
|
|
4
|
+
interface -- ``len()``, ``frames(i)``, ``label(name, i)``, and a
|
|
5
|
+
:class:`~eclise.clips.schema.ShardMeta`. Everything downstream
|
|
6
|
+
(:class:`~eclise.clips.dataset.ClipDataset`, the label helpers) is written against
|
|
7
|
+
that interface alone, so adding a format never touches the consumer.
|
|
8
|
+
|
|
9
|
+
Canonical shards need no adapter -- :class:`~eclise.clips.shards.ShardReader`
|
|
10
|
+
reads them directly. Adapters exist for layouts we do not control:
|
|
11
|
+
|
|
12
|
+
- :mod:`.phyworld_hdf5` -- upstream phyworld's published HDF5 files, which carry
|
|
13
|
+
no metadata.
|
|
14
|
+
|
|
15
|
+
Real video will land here too (a directory of mp4s with a sidecar manifest, or
|
|
16
|
+
webdataset tars), which is what lets a training project consume generated and
|
|
17
|
+
real clips through one code path.
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
from . import phyworld_hdf5
|
|
23
|
+
|
|
24
|
+
__all__ = ["phyworld_hdf5"]
|