eclise-phyworld 0.2.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_phyworld-0.2.0/PKG-INFO +16 -0
- eclise_phyworld-0.2.0/README.md +267 -0
- eclise_phyworld-0.2.0/pyproject.toml +27 -0
- eclise_phyworld-0.2.0/requirements.txt +57 -0
- eclise_phyworld-0.2.0/setup.cfg +4 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/__init__.py +64 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/analysis/__init__.py +41 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/analysis/metrics.py +201 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/analysis/state_recovery.py +275 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/analysis/verify.py +213 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/combinatorial_driver.py +302 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/config.py +338 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/generate.py +447 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/grids.py +368 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/render.py +477 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/__init__.py +51 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/base.py +177 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/collision.py +192 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/combinatorial.py +386 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/parabola.py +92 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/phyre_compat.py +400 -0
- eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/uniform_motion.py +207 -0
- eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/PKG-INFO +16 -0
- eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/SOURCES.txt +32 -0
- eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/dependency_links.txt +1 -0
- eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/requires.txt +13 -0
- eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/top_level.txt +1 -0
- eclise_phyworld-0.2.0/tests/test_config.py +157 -0
- eclise_phyworld-0.2.0/tests/test_generate.py +444 -0
- eclise_phyworld-0.2.0/tests/test_grids.py +148 -0
- eclise_phyworld-0.2.0/tests/test_phyre_compat.py +123 -0
- eclise_phyworld-0.2.0/tests/test_render.py +412 -0
- eclise_phyworld-0.2.0/tests/test_scenes.py +364 -0
- eclise_phyworld-0.2.0/tests/test_state_recovery.py +114 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: eclise-phyworld
|
|
3
|
+
Version: 0.2.0
|
|
4
|
+
Requires-Python: >=3.9
|
|
5
|
+
Requires-Dist: numpy<3,>=1.25
|
|
6
|
+
Requires-Dist: box2d-py>=2.3.5
|
|
7
|
+
Requires-Dist: h5py>=3.10
|
|
8
|
+
Requires-Dist: imageio[ffmpeg]>=2.36
|
|
9
|
+
Requires-Dist: av>=13.1
|
|
10
|
+
Requires-Dist: opencv-python-headless>=4.10
|
|
11
|
+
Requires-Dist: huggingface_hub<1.0,>=0.34.0
|
|
12
|
+
Requires-Dist: draccus
|
|
13
|
+
Requires-Dist: tqdm
|
|
14
|
+
Requires-Dist: matplotlib>=3.8
|
|
15
|
+
Provides-Extra: test
|
|
16
|
+
Requires-Dist: pytest~=8.4.0; extra == "test"
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
# phyworld
|
|
2
|
+
|
|
3
|
+
Synthetic physics video, reorganized from the code for [*How Far is Video
|
|
4
|
+
Generation from World Model: A Physical Law
|
|
5
|
+
Perspective*](https://phyworld.github.io/). Writes clip shards in the shared
|
|
6
|
+
[`eclise.clips`](../../data/clips) format, so `video_world_models`
|
|
7
|
+
consumes this sim's output through the same reader it will use for real video.
|
|
8
|
+
|
|
9
|
+
## Families
|
|
10
|
+
|
|
11
|
+
| Family | Physics | Objects | Needs |
|
|
12
|
+
|---|---|---|---|
|
|
13
|
+
| `uniform_motion` | constant velocity; position linear in time | 1 | Box2D |
|
|
14
|
+
| `parabola` | constant gravity; position quadratic in time | 1 | Box2D |
|
|
15
|
+
| `collision` | elastic 1-D collision, mass from radius | 2 | Box2D |
|
|
16
|
+
| `combinatorial` | PHYRE task templates | varies | PHYRE (see below) |
|
|
17
|
+
|
|
18
|
+
The first three need nothing beyond the main env. `collision` is the interesting one:
|
|
19
|
+
`density=1`, so mass follows from area and the radius ratio *is* the mass ratio —
|
|
20
|
+
a model has to infer mass from apparent size to predict the outcome.
|
|
21
|
+
|
|
22
|
+
## Setup
|
|
23
|
+
|
|
24
|
+
This project has **two** conda envs. PHYRE's only prebuilt wheel is `cp39`, so it
|
|
25
|
+
cannot live in the main 3.10 env; the second env exists solely to host it. Both
|
|
26
|
+
install the same `eclise.clips` and `eclise.phyworld`, so there is one `ShardWriter`
|
|
27
|
+
and one CLI either way.
|
|
28
|
+
|
|
29
|
+
| Env | Python | Spec | For |
|
|
30
|
+
|---|---|---|---|
|
|
31
|
+
| `.conda` | 3.10 | `environment.yml` | `uniform_motion`, `parabola`, `collision`; downloading; verifying |
|
|
32
|
+
| `.conda-phyre` | 3.9 | `environment-phyre.yml` | `combinatorial` only (adds `phyre==0.2.2`) |
|
|
33
|
+
|
|
34
|
+
### 1. Main env
|
|
35
|
+
|
|
36
|
+
```bash
|
|
37
|
+
cd data/phyworld
|
|
38
|
+
SCRATCH="${SCRATCH:-/pa/home/scratch_fcm/$USER}"
|
|
39
|
+
mkdir -p "$SCRATCH/eclise/data/phyworld"/{.conda,data,outputs}
|
|
40
|
+
conda env create -f environment.yml -p "$SCRATCH/eclise/data/phyworld/.conda"
|
|
41
|
+
for d in .conda data outputs; do
|
|
42
|
+
ln -sfn "$SCRATCH/eclise/data/phyworld/$d" ./$d
|
|
43
|
+
done
|
|
44
|
+
conda activate ./.conda
|
|
45
|
+
```
|
|
46
|
+
|
|
47
|
+
`data/` and `outputs/` must be scratch symlinks: `/pa/home` has a 100GB cap and
|
|
48
|
+
the published 3M splits are tens of GB each.
|
|
49
|
+
|
|
50
|
+
### 2. PHYRE env, only for the combinatorial family
|
|
51
|
+
|
|
52
|
+
Run this after step 1, which is what creates the shared `data/` and `outputs/`
|
|
53
|
+
symlinks. The script places the env on scratch, symlinks it in, and verifies the
|
|
54
|
+
simulator with a real rollout — an import alone passes on a broken extension.
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
bash scripts/setup_phyre.sh # create .conda-phyre, then verify
|
|
58
|
+
bash scripts/setup_phyre.sh --verify # re-verify an existing env, create nothing
|
|
59
|
+
conda activate ./.conda-phyre
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Skip this entirely if you only need the three Box2D families, or if you plan to
|
|
63
|
+
*read* the published combinatorial HDF5 — that needs no PHYRE at all. See
|
|
64
|
+
[The combinatorial family](#the-combinatorial-family) for why the wheel rather
|
|
65
|
+
than a source build.
|
|
66
|
+
|
|
67
|
+
## Generating
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
# smoke test
|
|
71
|
+
python scripts/generate.py --family collision --grid in_dist_l0 \
|
|
72
|
+
--experiment smoke --limit 32 --num_workers 1
|
|
73
|
+
|
|
74
|
+
# the ~30K training set
|
|
75
|
+
python scripts/generate.py --family uniform_motion --grid in_dist_l0 \
|
|
76
|
+
--experiment train_30k --num_workers 32
|
|
77
|
+
|
|
78
|
+
# an OOD evaluation regime
|
|
79
|
+
python scripts/generate.py --family parabola --grid ood_cross_small_fast \
|
|
80
|
+
--experiment eval_cross
|
|
81
|
+
|
|
82
|
+
# what grids does a family offer?
|
|
83
|
+
python scripts/generate.py --family collision --list_grids True
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
Shards go to `data/<family>/<experiment>/`, the run report to
|
|
87
|
+
`outputs/<experiment>/generate.json`. Verification runs by default; pass
|
|
88
|
+
`--no_verify True` to skip it. On a cluster, `sbatch scripts/generate.sbatch` takes
|
|
89
|
+
the same flags.
|
|
90
|
+
|
|
91
|
+
Boolean options need an explicit value — draccus parses them as typed dataclass
|
|
92
|
+
fields rather than argparse `store_true` flags, so a bare `--no_verify` consumes
|
|
93
|
+
the next token instead of toggling.
|
|
94
|
+
|
|
95
|
+
### Grids
|
|
96
|
+
|
|
97
|
+
Parameter sweeps are declarative data in `grids.py`, organized around which axes
|
|
98
|
+
leave the training range — because the question the paper asks is whether a model
|
|
99
|
+
that fits the training distribution generalizes outside it.
|
|
100
|
+
|
|
101
|
+
- `in_dist_l0` / `l1` / `l2` — training distribution at ~30K / 300K / 3M scenes.
|
|
102
|
+
- `ood_low_*` / `ood_high_*` — one or both axes below / above the training range.
|
|
103
|
+
`ood_low_velocity` reaches zero: a stationary ball is the easiest possible
|
|
104
|
+
dynamics, and a model that cannot do it has learned drift rather than motion.
|
|
105
|
+
- `ood_cross_*` — radius from one extreme, velocity from the other. Both marginals
|
|
106
|
+
appear in some other regime, so a failure here is about the *pairing*. That's the
|
|
107
|
+
distinction between "never saw this value" and "never saw this combination", and
|
|
108
|
+
it's the one a compositional generalization claim rests on.
|
|
109
|
+
|
|
110
|
+
## Downloading instead
|
|
111
|
+
|
|
112
|
+
The published data is often the better choice: it's what the paper's numbers were
|
|
113
|
+
computed on, so a model trained on it is directly comparable.
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
python scripts/download_phyworld.py --list
|
|
117
|
+
python scripts/download_phyworld.py --family uniform_motion --size 30K --eval
|
|
118
|
+
```
|
|
119
|
+
|
|
120
|
+
Those files carry **no metadata at all** — no frame rate, no world scale, no record
|
|
121
|
+
of which generator made them. `eclise.clips.readers.phyworld_hdf5` supplies it from
|
|
122
|
+
a per-family table, which is why reading them needs the family named:
|
|
123
|
+
|
|
124
|
+
```python
|
|
125
|
+
from eclise.clips import ClipDataset
|
|
126
|
+
from eclise.clips.readers import phyworld_hdf5
|
|
127
|
+
|
|
128
|
+
shards = phyworld_hdf5.open_shard_set("collision_30K.hdf5", family="collision")
|
|
129
|
+
data = ClipDataset(shards, velocity_object=0)
|
|
130
|
+
```
|
|
131
|
+
|
|
132
|
+
## Verifying
|
|
133
|
+
|
|
134
|
+
```bash
|
|
135
|
+
python scripts/verify_dataset.py --shard_dir data/collision/train_30k --plot
|
|
136
|
+
```
|
|
137
|
+
|
|
138
|
+
Recovers object positions from the rendered (and mp4-round-tripped) frames and
|
|
139
|
+
compares them to the stored labels. This catches the bug class that produces video
|
|
140
|
+
which looks fine and labels which are silently mirrored or rescaled — a y-axis flip
|
|
141
|
+
above all, since phyworld's stored positions have y increasing *upward* while image
|
|
142
|
+
rows increase downward. The same parser scores a *model's* generated frames, which
|
|
143
|
+
is how phyworld measures physical plausibility without a learned metric.
|
|
144
|
+
|
|
145
|
+
## The combinatorial family
|
|
146
|
+
|
|
147
|
+
PHYRE is a C++ extension whose **only prebuilt wheel is cp39**, so it cannot
|
|
148
|
+
live in the main Python 3.10 env. Building from source is also blocked:
|
|
149
|
+
`thrift-cpp=0.11.0` pins `openssl 1.0.*`, while every conda-forge Python ≥3.8
|
|
150
|
+
links openssl 1.1 or 3.x. The wheel sidesteps that — its extension links only
|
|
151
|
+
libstdc++/libm/libgcc/libc, with thrift/boost/openssl statically baked in —
|
|
152
|
+
which is why this family has a second, small env at Python 3.9 rather than a
|
|
153
|
+
toolchain fight.
|
|
154
|
+
|
|
155
|
+
Create that env with [Setup step 2](#2-phyre-env-only-for-the-combinatorial-family),
|
|
156
|
+
then generate from it:
|
|
157
|
+
|
|
158
|
+
```bash
|
|
159
|
+
conda activate ./.conda-phyre
|
|
160
|
+
python scripts/generate.py --family combinatorial --experiment combi_smoke \
|
|
161
|
+
--limit 8 --num_workers 1 \
|
|
162
|
+
--combinatorial.task_source upstream \
|
|
163
|
+
--combinatorial.templates '[0]' \
|
|
164
|
+
--combinatorial.tasks_per_template 2 \
|
|
165
|
+
--combinatorial.trials_per_task 2
|
|
166
|
+
```
|
|
167
|
+
|
|
168
|
+
On a cluster: `CONDA_ENV=.conda-phyre sbatch scripts/generate.sbatch --family combinatorial ...`.
|
|
169
|
+
|
|
170
|
+
The three Box2D families stay in `.conda`, and the *published* combinatorial
|
|
171
|
+
HDF5 reads with no PHYRE at all.
|
|
172
|
+
|
|
173
|
+
**Task sources.** `--combinatorial.task_source upstream` (256px) uses the
|
|
174
|
+
templates the wheel already ships. `--combinatorial.task_source fork` (default,
|
|
175
|
+
512px) stages phyworld's precompiled `tasks10.bin.lzma` (templates
|
|
176
|
+
`10000`–`10069`) beside them — a one-time download, no C++ rebuild. Shards
|
|
177
|
+
record `phyre_variant` and `terminates_on_solve`: the upstream wheel still
|
|
178
|
+
stops a rollout when the task is solved, which biases the family toward
|
|
179
|
+
unsolved trials at short `clip_len`. Pass
|
|
180
|
+
`--combinatorial.full_length_rollouts True` for the unbiased (slower) path.
|
|
181
|
+
|
|
182
|
+
**Its `frame_dt` is unverified.** Upstream computes `STRIDE = int(100/fps)`,
|
|
183
|
+
implying PHYRE steps at 100Hz, while PHYRE documents 60Hz — a factor of 5/3 on
|
|
184
|
+
every velocity derived from `object_features`. Shards record
|
|
185
|
+
`frame_dt_verified=false` until measured; set `--combinatorial.frame_dt` once you
|
|
186
|
+
have. Relative comparisons within the family are unaffected.
|
|
187
|
+
|
|
188
|
+
## Consuming from `video_world_models`
|
|
189
|
+
|
|
190
|
+
```python
|
|
191
|
+
from video_world_models.training import shard_data
|
|
192
|
+
|
|
193
|
+
bundle = shard_data.build_shard_clip_dataset(space, "data/phyworld/data/collision/train")
|
|
194
|
+
bundle.dataset[0] # (standardized latents [T', h, w, z], velocity [2], index)
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
`source_fps` is read off the shard (`1 / frame_dt` = 10.0 Hz for the Box2D
|
|
198
|
+
families) rather than guessed. Not `5`, which is what upstream's generators pass to
|
|
199
|
+
imageio for side-channel debug videos, and not the mp4 container header either.
|
|
200
|
+
Feeding the wrong rate to the FPS-aware encoder resamples by the wrong factor and
|
|
201
|
+
silently invalidates the velocity labels.
|
|
202
|
+
|
|
203
|
+
## Layout
|
|
204
|
+
|
|
205
|
+
```
|
|
206
|
+
src/eclise/phyworld/
|
|
207
|
+
├── config.py # draccus dataclasses; every knob, no module globals
|
|
208
|
+
├── grids.py # parameter sweeps as data
|
|
209
|
+
├── render.py # one rasterizer, and the y-flip boundary
|
|
210
|
+
├── generate.py # parallel driver -> ShardWriter
|
|
211
|
+
├── combinatorial_driver.py # the PHYRE family's driver (different unit of work)
|
|
212
|
+
├── scenes/ # one module per family
|
|
213
|
+
└── analysis/ # state recovery, metrics, verification
|
|
214
|
+
scripts/
|
|
215
|
+
├── generate.py generate.sbatch
|
|
216
|
+
├── download_phyworld.py
|
|
217
|
+
├── verify_dataset.py
|
|
218
|
+
└── setup_phyre.sh # creates .conda-phyre (no C++ build)
|
|
219
|
+
environment.yml # main env, python 3.10
|
|
220
|
+
environment-phyre.yml # PHYRE env, python 3.9 + phyre==0.2.2
|
|
221
|
+
```
|
|
222
|
+
|
|
223
|
+
## What changed from upstream
|
|
224
|
+
|
|
225
|
+
Upstream is four generator scripts plus a PHYRE fork, and the reorganization was
|
|
226
|
+
mostly deleting duplication and making implicit things explicit:
|
|
227
|
+
|
|
228
|
+
- **`frame_dt` is recorded.** Upstream writes clips with no `fps` at all while the
|
|
229
|
+
real spacing is `timestep * stride = 0.1s`, set in code a reader never sees. It's
|
|
230
|
+
now required metadata, and `source_fps` derives from it.
|
|
231
|
+
- **One rasterizer.** Upstream carries four copies of `vectorized_draw_balls`
|
|
232
|
+
differing only in the shape's distance function.
|
|
233
|
+
- **One grid builder.** Upstream has six near-identical sweep functions per
|
|
234
|
+
generator, each hardcoding bounds as locals (`MIN_OOD_V = 0.0` reassigned twice
|
|
235
|
+
in one function) and reading training bounds from globals set in `__main__`.
|
|
236
|
+
- **Config travels with the work.** Upstream's `ProcessPoolExecutor` workers read
|
|
237
|
+
`args`, `WORLD_SCALE`, `STRIDE`, `DIRECTION` from module globals, which resolves
|
|
238
|
+
only because `fork` copies parent memory. Under `spawn` every one is undefined.
|
|
239
|
+
- **Rejections are counted.** Upstream drops infeasible scenes with a bare
|
|
240
|
+
`continue`, so a run that asked for 30,000 clips and produced 28,412 gives no
|
|
241
|
+
hint of the gap. The shortfall is now tallied by reason.
|
|
242
|
+
- **Independent per-shard seeds.** Upstream calls `set_seeds(args.seed)` at the top
|
|
243
|
+
of every worker with the *same* seed, so every chunk draws the identical sequence
|
|
244
|
+
of random placements.
|
|
245
|
+
- **Feasibility is computed, not sampled.** The collision family's initial gap
|
|
246
|
+
follows from closing speed and window length; upstream retries placement 50 times
|
|
247
|
+
and its `num_try >= 10` check marks scenes failed that had already succeeded.
|
|
248
|
+
- **No merge pass.** Upstream has four inlined copies of `merge_files`, each
|
|
249
|
+
sorting filenames differently. `ShardSet` reads a directory as one sequence.
|
|
250
|
+
- **Pixel centres, not corners.** Upstream's `linspace(0, 1, size)` biases every
|
|
251
|
+
shape by half a pixel.
|
|
252
|
+
|
|
253
|
+
## Tests
|
|
254
|
+
|
|
255
|
+
```bash
|
|
256
|
+
cd data/phyworld
|
|
257
|
+
./.conda/bin/python -m pytest # main env
|
|
258
|
+
./.conda-phyre/bin/python -m pytest # PHYRE env, if created
|
|
259
|
+
```
|
|
260
|
+
|
|
261
|
+
The suite runs in either env. `test_scenes.py` needs Box2D; `test_generate.py`
|
|
262
|
+
additionally needs `eclise.clips` and OpenCV; the rollout tests in
|
|
263
|
+
`test_phyre_compat.py` need `phyre` and so only run in `.conda-phyre`. All skip
|
|
264
|
+
cleanly when their dependency is absent, which is why the two runs report
|
|
265
|
+
different counts rather than failing. `test_generate.py` includes a guard on the
|
|
266
|
+
guard: it mirrors the labels and asserts the verification *fails*, so the y-flip
|
|
267
|
+
check is known to be testing the convention rather than passing vacuously.
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "eclise-phyworld"
|
|
7
|
+
version = "0.2.0"
|
|
8
|
+
# 3.9 so this package installs into the PHYRE env as well as the main one:
|
|
9
|
+
# PHYRE's only prebuilt wheel is cp39. See README's "The combinatorial family".
|
|
10
|
+
# Consequence for annotations: any dataclass draccus introspects must spell
|
|
11
|
+
# optionals `Optional[X]`, since 3.9 cannot evaluate `X | None` at runtime.
|
|
12
|
+
requires-python = ">=3.9"
|
|
13
|
+
dynamic = ["dependencies"]
|
|
14
|
+
|
|
15
|
+
[project.optional-dependencies]
|
|
16
|
+
test = ["pytest~=8.4.0"]
|
|
17
|
+
|
|
18
|
+
[tool.setuptools.packages.find]
|
|
19
|
+
where = ["src"]
|
|
20
|
+
include = ["eclise.phyworld*"]
|
|
21
|
+
|
|
22
|
+
[tool.setuptools.dynamic]
|
|
23
|
+
dependencies = {file = ["requirements.txt"]}
|
|
24
|
+
|
|
25
|
+
[tool.pytest.ini_options]
|
|
26
|
+
testpaths = ["tests"]
|
|
27
|
+
pythonpath = ["src"]
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# =============================================================================
|
|
2
|
+
# eclise.phyworld runtime dependencies (synthetic physics video generation)
|
|
3
|
+
#
|
|
4
|
+
# This env owns simulators and writes clip shards. It never trains anything and
|
|
5
|
+
# never loads a video model, so it is deliberately torch-free: upstream's
|
|
6
|
+
# generators import torch only to call `torch.manual_seed`, which numpy's
|
|
7
|
+
# Generator covers. That keeps the env small and lets generation run on
|
|
8
|
+
# CPU-only nodes.
|
|
9
|
+
#
|
|
10
|
+
# Python 3.10 in the main env; the combinatorial family additionally installs
|
|
11
|
+
# into a Python 3.9 env (environment-phyre.yml) because PHYRE's only prebuilt
|
|
12
|
+
# wheel is cp39. See README's "The combinatorial family".
|
|
13
|
+
# =============================================================================
|
|
14
|
+
|
|
15
|
+
# >=1.25 for Generator.spawn(), which is how each shard gets an independent
|
|
16
|
+
# random stream (see generate.py's seeding note).
|
|
17
|
+
numpy>=1.25,<3
|
|
18
|
+
|
|
19
|
+
# --- Rigid-body physics ------------------------------------------------------
|
|
20
|
+
# The `Box2D` import name is provided by box2d-py (the maintained pybox2d fork
|
|
21
|
+
# with wheels). Upstream phyworld does `from Box2D import *`; we import the
|
|
22
|
+
# handful of names we use explicitly instead.
|
|
23
|
+
box2d-py>=2.3.5
|
|
24
|
+
|
|
25
|
+
# --- Shard IO ----------------------------------------------------------------
|
|
26
|
+
# `eclise.clips` (the shared clip contract) is installed editable from
|
|
27
|
+
# environment.yml's pip section (`-e ../clips`); editable path
|
|
28
|
+
# deps are not valid PEP 508 specifiers so they cannot live here. It brings h5py,
|
|
29
|
+
# imageio and av transitively; they are named here too because this project's own
|
|
30
|
+
# scripts use them directly.
|
|
31
|
+
h5py>=3.10
|
|
32
|
+
imageio[ffmpeg]>=2.36
|
|
33
|
+
av>=13.1
|
|
34
|
+
|
|
35
|
+
# --- State recovery from rendered frames -------------------------------------
|
|
36
|
+
# Hough circle detection for the verification/eval path. Headless: these envs
|
|
37
|
+
# run on compute nodes with no display, and plain opencv-python pulls in GUI
|
|
38
|
+
# libraries that then fail to load.
|
|
39
|
+
opencv-python-headless>=4.10
|
|
40
|
+
|
|
41
|
+
# --- Downloading the published datasets --------------------------------------
|
|
42
|
+
huggingface_hub>=0.34.0,<1.0
|
|
43
|
+
|
|
44
|
+
# --- CLI + progress ----------------------------------------------------------
|
|
45
|
+
draccus # dataclass CLI config (as in models/robotics/libero, hierarchical_robotics)
|
|
46
|
+
tqdm
|
|
47
|
+
|
|
48
|
+
# --- Figures for the verification report -------------------------------------
|
|
49
|
+
matplotlib>=3.8
|
|
50
|
+
|
|
51
|
+
# NOTE: PHYRE (the combinatorial family's simulator) is NOT a pip requirement of
|
|
52
|
+
# this file. Its only prebuilt wheel is cp39, so it lives in a second conda env
|
|
53
|
+
# created by `scripts/setup_phyre.sh` (see environment-phyre.yml). That env
|
|
54
|
+
# installs the same editable packages as the main one, so combinatorial
|
|
55
|
+
# generation still runs `scripts/generate.py` and writes through the same
|
|
56
|
+
# ShardWriter. Every other family works without it, and the published
|
|
57
|
+
# combinatorial HDF5 can be *read* with no PHYRE present at all.
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""Synthetic physics video generation, reorganized from the phyworld paper's code.
|
|
2
|
+
|
|
3
|
+
Produces clip shards in the shared :mod:`eclise.clips` format, so a training
|
|
4
|
+
project consumes this sim's output through the same reader it will use for real
|
|
5
|
+
video.
|
|
6
|
+
|
|
7
|
+
Four families:
|
|
8
|
+
|
|
9
|
+
=================== ==========================================================
|
|
10
|
+
``uniform_motion`` one ball, constant velocity -- position linear in time
|
|
11
|
+
``parabola`` one ball under gravity -- position quadratic in time
|
|
12
|
+
``collision`` two balls, elastic, unequal masses from unequal radii
|
|
13
|
+
``combinatorial`` PHYRE templates (needs ``scripts/setup_phyre.sh``)
|
|
14
|
+
=================== ==========================================================
|
|
15
|
+
|
|
16
|
+
The first three are pure Box2D and need nothing beyond this env.
|
|
17
|
+
|
|
18
|
+
from eclise.phyworld import GenerateConfig, generate
|
|
19
|
+
|
|
20
|
+
report = generate(GenerateConfig(family="collision", grid="in_dist_l0"))
|
|
21
|
+
print(report.realized, "clips;", report.rejected, "rejected")
|
|
22
|
+
|
|
23
|
+
Reading back is the consumer's job, through :mod:`eclise.clips`:
|
|
24
|
+
|
|
25
|
+
from eclise.clips import ClipDataset
|
|
26
|
+
data = ClipDataset("data/collision/default")
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
from __future__ import annotations
|
|
30
|
+
|
|
31
|
+
from .config import (
|
|
32
|
+
BOX2D_FAMILIES,
|
|
33
|
+
EVAL_TEMPLATES,
|
|
34
|
+
FAMILIES,
|
|
35
|
+
CombinatorialConfig,
|
|
36
|
+
GenerateConfig,
|
|
37
|
+
)
|
|
38
|
+
from .generate import GenerationReport, generate
|
|
39
|
+
from .grids import Axis, ParamGrid, all_grids, resolve_grid
|
|
40
|
+
from .render import RenderConfig, Trajectory, render_clip, render_velocity_field
|
|
41
|
+
from .scenes import Collision, Parabola, SimConfig, UniformMotion, build_scene
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"BOX2D_FAMILIES",
|
|
45
|
+
"EVAL_TEMPLATES",
|
|
46
|
+
"FAMILIES",
|
|
47
|
+
"Axis",
|
|
48
|
+
"Collision",
|
|
49
|
+
"CombinatorialConfig",
|
|
50
|
+
"GenerateConfig",
|
|
51
|
+
"GenerationReport",
|
|
52
|
+
"Parabola",
|
|
53
|
+
"ParamGrid",
|
|
54
|
+
"RenderConfig",
|
|
55
|
+
"SimConfig",
|
|
56
|
+
"Trajectory",
|
|
57
|
+
"UniformMotion",
|
|
58
|
+
"all_grids",
|
|
59
|
+
"build_scene",
|
|
60
|
+
"generate",
|
|
61
|
+
"render_clip",
|
|
62
|
+
"render_velocity_field",
|
|
63
|
+
"resolve_grid",
|
|
64
|
+
]
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""Recovering state from frames, and scoring it.
|
|
2
|
+
|
|
3
|
+
Serves two purposes with one implementation. On *our* frames it verifies that
|
|
4
|
+
pixels and labels agree, which is how a coordinate-convention bug gets caught
|
|
5
|
+
before it becomes training data. On a *model's* generated frames the same parser
|
|
6
|
+
yields the positions its output implies, which is phyworld's way of scoring
|
|
7
|
+
physical plausibility without a learned metric.
|
|
8
|
+
|
|
9
|
+
from eclise.phyworld.analysis import verify_shards
|
|
10
|
+
print(verify_shards("data/collision/default").summary_text())
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
from .metrics import ClipError, ErrorSummary, clip_error, compare_to_labels, summarize
|
|
16
|
+
from .state_recovery import (
|
|
17
|
+
Detection,
|
|
18
|
+
RecoveredClip,
|
|
19
|
+
color_mask,
|
|
20
|
+
detect_by_color,
|
|
21
|
+
detect_by_hough,
|
|
22
|
+
recover_clip,
|
|
23
|
+
)
|
|
24
|
+
from .verify import VerifyResult, upstream_opener, verify_shards
|
|
25
|
+
|
|
26
|
+
__all__ = [
|
|
27
|
+
"ClipError",
|
|
28
|
+
"Detection",
|
|
29
|
+
"ErrorSummary",
|
|
30
|
+
"RecoveredClip",
|
|
31
|
+
"VerifyResult",
|
|
32
|
+
"clip_error",
|
|
33
|
+
"color_mask",
|
|
34
|
+
"compare_to_labels",
|
|
35
|
+
"detect_by_color",
|
|
36
|
+
"detect_by_hough",
|
|
37
|
+
"recover_clip",
|
|
38
|
+
"summarize",
|
|
39
|
+
"upstream_opener",
|
|
40
|
+
"verify_shards",
|
|
41
|
+
]
|
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""Comparing recovered state against ground truth.
|
|
2
|
+
|
|
3
|
+
Two comparisons matter, and they answer different questions. *Position error* asks
|
|
4
|
+
whether objects are where they should be -- sensitive to a coordinate-convention
|
|
5
|
+
mistake, which is exactly the class of bug that produces plausible-looking video
|
|
6
|
+
and unusable labels. *Velocity error* asks whether they move correctly, which is
|
|
7
|
+
what a dynamics model is actually being asked to learn and is insensitive to a
|
|
8
|
+
constant offset.
|
|
9
|
+
|
|
10
|
+
Everything here works in **pixels with y downward**. Labels arrive in world units
|
|
11
|
+
with y up, so put them through
|
|
12
|
+
:func:`eclise.clips.labels.positions_to_pixels` first;
|
|
13
|
+
:func:`compare_to_labels` does that for you.
|
|
14
|
+
"""
|
|
15
|
+
|
|
16
|
+
from __future__ import annotations
|
|
17
|
+
|
|
18
|
+
from dataclasses import dataclass, field
|
|
19
|
+
|
|
20
|
+
import numpy as np
|
|
21
|
+
|
|
22
|
+
from .state_recovery import RecoveredClip
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass
|
|
26
|
+
class ClipError:
|
|
27
|
+
"""Discrepancy between recovered and true state for one clip.
|
|
28
|
+
|
|
29
|
+
Attributes:
|
|
30
|
+
position_rmse: root-mean-square centre distance, pixels, over frames
|
|
31
|
+
where the object was detected.
|
|
32
|
+
position_max: worst single-frame distance, pixels. The one that catches a
|
|
33
|
+
y flip -- a mirrored trajectory can have a modest RMSE while being
|
|
34
|
+
wrong everywhere.
|
|
35
|
+
velocity_rmse: RMS error of per-step velocity, px/frame.
|
|
36
|
+
radius_error: mean absolute radius error, pixels.
|
|
37
|
+
detection_rate: fraction of (frame, object) slots detected.
|
|
38
|
+
compared_frames: frames that entered the comparison.
|
|
39
|
+
"""
|
|
40
|
+
|
|
41
|
+
position_rmse: float
|
|
42
|
+
position_max: float
|
|
43
|
+
velocity_rmse: float
|
|
44
|
+
radius_error: float
|
|
45
|
+
detection_rate: float
|
|
46
|
+
compared_frames: int
|
|
47
|
+
|
|
48
|
+
def to_dict(self) -> dict[str, float | int]:
|
|
49
|
+
return {
|
|
50
|
+
"position_rmse": round(self.position_rmse, 4),
|
|
51
|
+
"position_max": round(self.position_max, 4),
|
|
52
|
+
"velocity_rmse": round(self.velocity_rmse, 4),
|
|
53
|
+
"radius_error": round(self.radius_error, 4),
|
|
54
|
+
"detection_rate": round(self.detection_rate, 4),
|
|
55
|
+
"compared_frames": self.compared_frames,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def clip_error(
|
|
60
|
+
recovered: RecoveredClip,
|
|
61
|
+
true_centers: np.ndarray,
|
|
62
|
+
*,
|
|
63
|
+
true_radii: np.ndarray | None = None,
|
|
64
|
+
) -> ClipError:
|
|
65
|
+
"""Compare one recovered clip against ground truth in pixel coordinates.
|
|
66
|
+
|
|
67
|
+
Args:
|
|
68
|
+
recovered: output of :func:`~.state_recovery.recover_clip`.
|
|
69
|
+
true_centers: ``[T, n_objects, 2]`` in pixels, y downward.
|
|
70
|
+
true_radii: ``[n_objects]`` or ``[T, n_objects]`` in pixels.
|
|
71
|
+
|
|
72
|
+
Undetected slots are excluded rather than counted as zero error; the
|
|
73
|
+
detection rate reports how many there were, so a clip that failed to detect
|
|
74
|
+
anything cannot masquerade as a perfect one.
|
|
75
|
+
"""
|
|
76
|
+
true_centers = np.asarray(true_centers, dtype=np.float64)
|
|
77
|
+
if true_centers.shape != recovered.centers.shape:
|
|
78
|
+
raise ValueError(
|
|
79
|
+
f"recovered centers are {recovered.centers.shape} but truth is "
|
|
80
|
+
f"{true_centers.shape}"
|
|
81
|
+
)
|
|
82
|
+
mask = recovered.found
|
|
83
|
+
if not mask.any():
|
|
84
|
+
return ClipError(
|
|
85
|
+
position_rmse=float("nan"),
|
|
86
|
+
position_max=float("nan"),
|
|
87
|
+
velocity_rmse=float("nan"),
|
|
88
|
+
radius_error=float("nan"),
|
|
89
|
+
detection_rate=0.0,
|
|
90
|
+
compared_frames=0,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
distances = np.linalg.norm(recovered.centers - true_centers, axis=-1)
|
|
94
|
+
valid = distances[mask]
|
|
95
|
+
|
|
96
|
+
radius_error = float("nan")
|
|
97
|
+
if true_radii is not None:
|
|
98
|
+
tr = np.asarray(true_radii, dtype=np.float64)
|
|
99
|
+
if tr.ndim == 1:
|
|
100
|
+
tr = np.broadcast_to(tr, recovered.radii.shape)
|
|
101
|
+
radius_error = float(np.abs(recovered.radii - tr)[mask].mean())
|
|
102
|
+
|
|
103
|
+
return ClipError(
|
|
104
|
+
position_rmse=float(np.sqrt((valid**2).mean())),
|
|
105
|
+
position_max=float(valid.max()),
|
|
106
|
+
velocity_rmse=_velocity_rmse(recovered, true_centers),
|
|
107
|
+
radius_error=radius_error,
|
|
108
|
+
detection_rate=recovered.detection_rate,
|
|
109
|
+
compared_frames=int(mask.sum()),
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def _velocity_rmse(recovered: RecoveredClip, true_centers: np.ndarray) -> float:
|
|
114
|
+
"""RMS per-step velocity error over consecutive frames both detected in."""
|
|
115
|
+
both = recovered.found[:-1] & recovered.found[1:]
|
|
116
|
+
if not both.any():
|
|
117
|
+
return float("nan")
|
|
118
|
+
got = np.diff(recovered.centers, axis=0)
|
|
119
|
+
want = np.diff(true_centers, axis=0)
|
|
120
|
+
errors = np.linalg.norm(got - want, axis=-1)[both]
|
|
121
|
+
return float(np.sqrt((errors**2).mean()))
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def compare_to_labels(
|
|
125
|
+
recovered: RecoveredClip,
|
|
126
|
+
positions: np.ndarray,
|
|
127
|
+
spec,
|
|
128
|
+
*,
|
|
129
|
+
image_size: tuple[int, int],
|
|
130
|
+
world_scale: float | None,
|
|
131
|
+
radii: np.ndarray | None = None,
|
|
132
|
+
) -> ClipError:
|
|
133
|
+
"""Compare against stored labels, converting them to pixels first.
|
|
134
|
+
|
|
135
|
+
This is the function to use for verification: it routes the labels through
|
|
136
|
+
:func:`eclise.clips.labels.positions_to_pixels`, so the y flip that
|
|
137
|
+
``spec.y_axis_up`` declares is applied rather than assumed either way.
|
|
138
|
+
"""
|
|
139
|
+
from eclise.clips.labels import positions_to_pixels
|
|
140
|
+
|
|
141
|
+
true_centers = positions_to_pixels(
|
|
142
|
+
positions, spec, image_size=image_size, world_scale=world_scale
|
|
143
|
+
)
|
|
144
|
+
true_radii = None
|
|
145
|
+
if radii is not None and world_scale:
|
|
146
|
+
# Radii scale with the image, and only isotropically for a square frame.
|
|
147
|
+
true_radii = np.asarray(radii, dtype=np.float64) * (
|
|
148
|
+
image_size[1] / world_scale
|
|
149
|
+
)
|
|
150
|
+
return clip_error(recovered, true_centers, true_radii=true_radii)
|
|
151
|
+
|
|
152
|
+
|
|
153
|
+
@dataclass
|
|
154
|
+
class ErrorSummary:
|
|
155
|
+
"""Aggregate over many clips."""
|
|
156
|
+
|
|
157
|
+
clips: int = 0
|
|
158
|
+
position_rmse: float = float("nan")
|
|
159
|
+
position_rmse_p95: float = float("nan")
|
|
160
|
+
position_max: float = float("nan")
|
|
161
|
+
velocity_rmse: float = float("nan")
|
|
162
|
+
detection_rate: float = float("nan")
|
|
163
|
+
failed_clips: int = 0
|
|
164
|
+
per_clip: list[ClipError] = field(default_factory=list)
|
|
165
|
+
|
|
166
|
+
def to_dict(self) -> dict[str, float | int]:
|
|
167
|
+
return {
|
|
168
|
+
"clips": self.clips,
|
|
169
|
+
"position_rmse": round(self.position_rmse, 4),
|
|
170
|
+
"position_rmse_p95": round(self.position_rmse_p95, 4),
|
|
171
|
+
"position_max": round(self.position_max, 4),
|
|
172
|
+
"velocity_rmse": round(self.velocity_rmse, 4),
|
|
173
|
+
"detection_rate": round(self.detection_rate, 4),
|
|
174
|
+
"failed_clips": self.failed_clips,
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
|
|
178
|
+
def summarize(errors: list[ClipError]) -> ErrorSummary:
|
|
179
|
+
"""Aggregate per-clip errors, reporting the p95 as well as the mean.
|
|
180
|
+
|
|
181
|
+
The p95 is there because a handful of bad clips is a different problem from a
|
|
182
|
+
uniformly small bias, and a mean cannot tell them apart.
|
|
183
|
+
"""
|
|
184
|
+
if not errors:
|
|
185
|
+
return ErrorSummary()
|
|
186
|
+
usable = [e for e in errors if e.compared_frames > 0]
|
|
187
|
+
if not usable:
|
|
188
|
+
return ErrorSummary(clips=len(errors), failed_clips=len(errors), per_clip=errors)
|
|
189
|
+
rmses = np.array([e.position_rmse for e in usable])
|
|
190
|
+
return ErrorSummary(
|
|
191
|
+
clips=len(errors),
|
|
192
|
+
position_rmse=float(rmses.mean()),
|
|
193
|
+
position_rmse_p95=float(np.percentile(rmses, 95)),
|
|
194
|
+
position_max=float(max(e.position_max for e in usable)),
|
|
195
|
+
velocity_rmse=float(
|
|
196
|
+
np.nanmean([e.velocity_rmse for e in usable])
|
|
197
|
+
),
|
|
198
|
+
detection_rate=float(np.mean([e.detection_rate for e in errors])),
|
|
199
|
+
failed_clips=len(errors) - len(usable),
|
|
200
|
+
per_clip=errors,
|
|
201
|
+
)
|