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.
Files changed (34) hide show
  1. eclise_phyworld-0.2.0/PKG-INFO +16 -0
  2. eclise_phyworld-0.2.0/README.md +267 -0
  3. eclise_phyworld-0.2.0/pyproject.toml +27 -0
  4. eclise_phyworld-0.2.0/requirements.txt +57 -0
  5. eclise_phyworld-0.2.0/setup.cfg +4 -0
  6. eclise_phyworld-0.2.0/src/eclise/phyworld/__init__.py +64 -0
  7. eclise_phyworld-0.2.0/src/eclise/phyworld/analysis/__init__.py +41 -0
  8. eclise_phyworld-0.2.0/src/eclise/phyworld/analysis/metrics.py +201 -0
  9. eclise_phyworld-0.2.0/src/eclise/phyworld/analysis/state_recovery.py +275 -0
  10. eclise_phyworld-0.2.0/src/eclise/phyworld/analysis/verify.py +213 -0
  11. eclise_phyworld-0.2.0/src/eclise/phyworld/combinatorial_driver.py +302 -0
  12. eclise_phyworld-0.2.0/src/eclise/phyworld/config.py +338 -0
  13. eclise_phyworld-0.2.0/src/eclise/phyworld/generate.py +447 -0
  14. eclise_phyworld-0.2.0/src/eclise/phyworld/grids.py +368 -0
  15. eclise_phyworld-0.2.0/src/eclise/phyworld/render.py +477 -0
  16. eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/__init__.py +51 -0
  17. eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/base.py +177 -0
  18. eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/collision.py +192 -0
  19. eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/combinatorial.py +386 -0
  20. eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/parabola.py +92 -0
  21. eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/phyre_compat.py +400 -0
  22. eclise_phyworld-0.2.0/src/eclise/phyworld/scenes/uniform_motion.py +207 -0
  23. eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/PKG-INFO +16 -0
  24. eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/SOURCES.txt +32 -0
  25. eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/dependency_links.txt +1 -0
  26. eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/requires.txt +13 -0
  27. eclise_phyworld-0.2.0/src/eclise_phyworld.egg-info/top_level.txt +1 -0
  28. eclise_phyworld-0.2.0/tests/test_config.py +157 -0
  29. eclise_phyworld-0.2.0/tests/test_generate.py +444 -0
  30. eclise_phyworld-0.2.0/tests/test_grids.py +148 -0
  31. eclise_phyworld-0.2.0/tests/test_phyre_compat.py +123 -0
  32. eclise_phyworld-0.2.0/tests/test_render.py +412 -0
  33. eclise_phyworld-0.2.0/tests/test_scenes.py +364 -0
  34. 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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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
+ )