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.
Files changed (106) hide show
  1. eclise/clips/__init__.py +97 -0
  2. eclise/clips/codec.py +85 -0
  3. eclise/clips/dataset.py +237 -0
  4. eclise/clips/labels.py +198 -0
  5. eclise/clips/readers/__init__.py +24 -0
  6. eclise/clips/readers/phyworld_hdf5.py +489 -0
  7. eclise/clips/schema.py +302 -0
  8. eclise/clips/shards.py +487 -0
  9. eclise/datasets/__init__.py +17 -0
  10. eclise/datasets/_resources/imagenet_classes.json +5250 -0
  11. eclise/datasets/imagenet.py +131 -0
  12. eclise/datasets/labels.py +65 -0
  13. eclise/launch/__init__.py +24 -0
  14. eclise/launch/__main__.py +125 -0
  15. eclise/launch/_version.py +3 -0
  16. eclise/launch/errors.py +5 -0
  17. eclise/launch/launcher.py +216 -0
  18. eclise/launch/pixi.py +181 -0
  19. eclise/launch/registry.py +69 -0
  20. eclise/launch/root.py +153 -0
  21. eclise/libero/__init__.py +27 -0
  22. eclise/libero/evaluation/__init__.py +11 -0
  23. eclise/libero/evaluation/config.py +63 -0
  24. eclise/libero/evaluation/metrics.py +36 -0
  25. eclise/libero/evaluation/runner.py +174 -0
  26. eclise/libero/harness/__init__.py +18 -0
  27. eclise/libero/harness/env.py +88 -0
  28. eclise/libero/harness/libero_env.py +107 -0
  29. eclise/libero/harness/rollout.py +14 -0
  30. eclise/libero/observation.py +32 -0
  31. eclise/libero/serve.py +230 -0
  32. eclise/octo/__init__.py +14 -0
  33. eclise/octo/obs_adapter.py +190 -0
  34. eclise/octo/policy.py +133 -0
  35. eclise/octo/serve.py +76 -0
  36. eclise/octo/training/__init__.py +39 -0
  37. eclise/octo/training/config.py +76 -0
  38. eclise/octo/training/data.py +507 -0
  39. eclise/octo/training/step.py +47 -0
  40. eclise/octo/training/train_state.py +102 -0
  41. eclise/octo/training/transforms.py +53 -0
  42. eclise/openvid/__init__.py +58 -0
  43. eclise/openvid/config.py +183 -0
  44. eclise/openvid/hub.py +178 -0
  45. eclise/openvid/ingest.py +526 -0
  46. eclise/openvid/metadata.py +342 -0
  47. eclise/openvid/split.py +33 -0
  48. eclise/openvid/video.py +291 -0
  49. eclise/phyworld/__init__.py +66 -0
  50. eclise/phyworld/analysis/__init__.py +41 -0
  51. eclise/phyworld/analysis/metrics.py +201 -0
  52. eclise/phyworld/analysis/state_recovery.py +275 -0
  53. eclise/phyworld/analysis/verify.py +213 -0
  54. eclise/phyworld/combinatorial_driver.py +302 -0
  55. eclise/phyworld/config.py +353 -0
  56. eclise/phyworld/generate.py +447 -0
  57. eclise/phyworld/grids.py +368 -0
  58. eclise/phyworld/render.py +477 -0
  59. eclise/phyworld/scenes/__init__.py +51 -0
  60. eclise/phyworld/scenes/base.py +177 -0
  61. eclise/phyworld/scenes/collision.py +192 -0
  62. eclise/phyworld/scenes/combinatorial.py +386 -0
  63. eclise/phyworld/scenes/parabola.py +92 -0
  64. eclise/phyworld/scenes/phyre_compat.py +400 -0
  65. eclise/phyworld/scenes/uniform_motion.py +207 -0
  66. eclise/pi0/__init__.py +17 -0
  67. eclise/smolvla/__init__.py +1 -0
  68. eclise/smolvla/obs_adapter.py +89 -0
  69. eclise/smolvla/policy.py +118 -0
  70. eclise/smolvla/serve.py +67 -0
  71. eclise/utils/__init__.py +103 -0
  72. eclise/utils/are.py +409 -0
  73. eclise/utils/arrays.py +93 -0
  74. eclise/utils/dicts.py +85 -0
  75. eclise/utils/ensemble.py +137 -0
  76. eclise/utils/fast_conv_scan.py +171 -0
  77. eclise/utils/io.py +40 -0
  78. eclise/utils/labeled_array.py +213 -0
  79. eclise/utils/linalg.py +285 -0
  80. eclise/utils/memory.py +61 -0
  81. eclise/utils/modules.py +243 -0
  82. eclise/utils/ode.py +77 -0
  83. eclise/utils/optim.py +34 -0
  84. eclise/utils/plotting.py +50 -0
  85. eclise/utils/recursive.py +50 -0
  86. eclise/utils/scan.py +67 -0
  87. eclise/utils/settings.py +137 -0
  88. eclise/utils/timing.py +96 -0
  89. eclise/utils/types.py +19 -0
  90. eclise/vla/__init__.py +74 -0
  91. eclise/vla/_ws.py +23 -0
  92. eclise/vla/env.py +70 -0
  93. eclise/vla/env_client.py +104 -0
  94. eclise/vla/env_server.py +134 -0
  95. eclise/vla/msgpack_numpy.py +58 -0
  96. eclise/vla/observation.py +78 -0
  97. eclise/vla/policy.py +60 -0
  98. eclise/vla/policy_client.py +95 -0
  99. eclise/vla/rollout.py +181 -0
  100. eclise/vla/serve_dummy.py +30 -0
  101. eclise/vla/server.py +93 -0
  102. eclise-0.1.0.dist-info/METADATA +183 -0
  103. eclise-0.1.0.dist-info/RECORD +106 -0
  104. eclise-0.1.0.dist-info/WHEEL +5 -0
  105. eclise-0.1.0.dist-info/licenses/LICENSE +21 -0
  106. eclise-0.1.0.dist-info/top_level.txt +1 -0
@@ -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
@@ -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"]