twanghcmut/backup-foundation-physics / scripts /run_physics_identification.py
twanghcmut's picture
download
raw
20 kB
#!/usr/bin/env python
"""S9 driver: physics-parameter identification, one episode or a whole batch, pooled.
Thin CLI over the same ``fpgm.physics`` modules :meth:`fpgm.datagen.pipeline.
EpisodePipeline._run_s9` wires into the per-episode pipeline -- but this script's
job is the one thing a per-episode pipeline stage structurally cannot do:
**pool evidence across episodes**. Every :class:`~fpgm.physics.types.
EpisodeLogLik` from every episode of the same physical object (grouped by
``(scene_id, label)``, matching how :mod:`fpgm.physics.priors`' VLM cache is
already keyed and why -- see that module's own docstring) is summed into one
posterior via ``fpgm.physics.inference.accumulate``/``pool``.
**Non-negotiable design constraint: no episode is ever filtered.** Every
episode with an S6 pose track (``poses.npz``) is scored and its per-particle
log-likelihood is folded into its object's pool -- there is no ``--min-motion``
flag, no skip-if-static branch, and none should ever be added. The reason this
is safe, not merely convenient, is arithmetic, not policy: an object that did
not move produces (up to floating point) the *same* predicted pose for every
particle a physically-faithful simulator would draw (see
``fpgm.physics.types``'s own module docstring and
``scripts/_mujoco_settle_worker.py``'s proof that a rigid body's fall-and-settle
trajectory is mass-independent), so ``EpisodeLogLik.loglik`` for that episode is
a near-constant vector. Adding a constant to every particle's log-weight before
a softmax leaves the softmax exactly unchanged -- so an uninformative episode
contributes nothing, automatically, with no threshold anywhere in this file
deciding which episodes get to vote. ``tests/test_run_physics_identification.py``
asserts this end to end: a pool that includes a fully static object's episode
produces a bit-identical posterior to the same pool without it.
That does not mean every episode is *interesting* -- it means every episode is
*included*. ``spread_nats`` (``EpisodeLogLik.spread_nats``, printed per episode
below) is the diagnostic for "was this one interesting": near zero means the
episode said nothing about theta. It is printed as a report for a human to
read, never consulted by this script to decide what to score.
Usage::
# One episode:
PYTHONPATH=src python scripts/run_physics_identification.py \\
--uuid AUTOLab+0d4edc83+2023-10-21-19h-07m-04s --camera ext1
# Every episode with S6 output under a batch's output_root, pooled:
PYTHONPATH=src python scripts/run_physics_identification.py \\
--from-batch outputs/datagen --particles 1024 --seed 0 --out outputs/physics
"""
from __future__ import annotations
import argparse
import json
import sys
from collections.abc import Sequence
from contextlib import nullcontext
from pathlib import Path
from typing import Any
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
from fpgm.datagen.episode_spec import EpisodeMetadata # noqa: E402
from fpgm.utils.io import ensure_dir # noqa: E402
from fpgm.utils.logging import get_logger, setup_logging # noqa: E402
logger = get_logger("run_physics_identification")
_DEFAULT_CONFIG = REPO_ROOT / "configs" / "datagen_droid.yaml"
_DEFAULT_PARTICLES = 512
_DEFAULT_SEED = 0
_DEFAULT_OUT_ROOT = REPO_ROOT / "outputs" / "physics"
__all__ = ["EpisodeRef", "discover_episodes_from_batch", "run_physics_identification"]
# --------------------------------------------------------------------------- #
# timer=None no-op, matching fpgm.physics.likelihood/inference's own helper
# --------------------------------------------------------------------------- #
def _step(timer: Any, label: str, **fields: Any):
"""``timer.step(label, **fields)`` if ``timer`` is given, else a clean no-op.
Same pattern as ``fpgm.physics.likelihood._step``/``inference._step`` --
duplicated rather than imported so this script has no import-time
dependency on those modules (they are imported lazily inside
:func:`run_physics_identification`, see that function's docstring).
"""
if timer is None:
return nullcontext()
return timer.step(label, **fields)
# --------------------------------------------------------------------------- #
# Episode discovery
# --------------------------------------------------------------------------- #
class EpisodeRef:
"""One episode this run will score: ``(uuid, camera_serial, master_dir)``."""
__slots__ = ("uuid", "camera_serial", "master_dir")
def __init__(self, uuid: str, camera_serial: str, master_dir: Path) -> None:
self.uuid = uuid
self.camera_serial = camera_serial
self.master_dir = Path(master_dir)
def __repr__(self) -> str: # pragma: no cover - debug convenience only
return f"EpisodeRef({self.uuid!r}, {self.camera_serial!r}, {self.master_dir})"
def discover_episodes_from_batch(batch_root: Path) -> list[EpisodeRef]:
"""Every ``(uuid, camera_serial)`` under ``batch_root`` with an S6 pose track.
"Has ``poses.npz``" is an *existence* check (did S6 run at all for this
episode/camera), not an informativeness one -- see the module docstring's
design constraint. An episode/camera pair with no ``poses.npz`` was never
run through S6 in the first place; there is nothing here to score, which
is a different statement from "this episode carried no information" (that
one IS scored, and nets out to a no-op on its own -- see the module
docstring).
"""
batch_root = Path(batch_root)
if not batch_root.is_dir():
raise FileNotFoundError(f"--from-batch root does not exist or is not a dir: {batch_root}")
refs: list[EpisodeRef] = []
for uuid_dir in sorted(p for p in batch_root.iterdir() if p.is_dir()):
for cam_dir in sorted(p for p in uuid_dir.iterdir() if p.is_dir()):
master_dir = cam_dir / "master"
if (master_dir / "poses.npz").exists():
refs.append(EpisodeRef(uuid_dir.name, cam_dir.name, master_dir))
return refs
def _scene_id_for(ref: EpisodeRef) -> str:
"""Read ``scene_id`` from the episode's own ``episode_spec.json``.
Written unconditionally by every :meth:`~fpgm.datagen.pipeline.
EpisodePipeline.run` call (see that class's own ``run`` method), so any
episode with S6 output also has this file -- except a partial/corrupted
``master/`` directory, which is a real data problem. Rather than crash
the whole batch pool over one episode's missing spec file, this episode
becomes its own single-member "scene" (grouped under its own uuid) --
still scored, still pooled (of one), just not merged with sibling
episodes of what may be the same physical object. Recorded via a log
line, not silently absorbed.
"""
spec_path = ref.master_dir.parent / "episode_spec.json"
if not spec_path.exists():
# master_dir is .../<uuid>/<camera_serial>/master -- master_dir.parent
# is .../<uuid>/<camera_serial>, and episode_spec.json is written
# at .../<uuid>/<camera_serial>/episode_spec.json by EpisodePipeline.run.
logger.warning(
"%s/%s: no episode_spec.json at %s -- cannot group by scene_id; treating this "
"episode as its own scene (pooled alone)",
ref.uuid, ref.camera_serial, spec_path,
)
return f"uuid:{ref.uuid}"
try:
return str(json.loads(spec_path.read_text())["scene_id"])
except (json.JSONDecodeError, KeyError, OSError) as exc:
logger.warning(
"%s/%s: could not read scene_id from %s (%s); treating this episode as its own "
"scene (pooled alone)",
ref.uuid, ref.camera_serial, spec_path, exc,
)
return f"uuid:{ref.uuid}"
# --------------------------------------------------------------------------- #
# Core, testable logic
# --------------------------------------------------------------------------- #
def run_physics_identification(
episodes: Sequence[EpisodeRef],
*,
n_particles: int = _DEFAULT_PARTICLES,
seed: int = _DEFAULT_SEED,
out_root: Path,
timer: Any = None,
) -> dict[str, Any]:
"""Score every episode in ``episodes``, pool by ``(scene_id, label)``, write reports.
Imports every ``fpgm.physics.*`` module lazily (not at this file's module
scope), for the same reason
:meth:`~fpgm.datagen.pipeline.EpisodePipeline._run_s9` does -- see that
method's own docstring: these modules were written by other agents in
parallel with this script, and a module-scope import would make the
entire CLI fail to even parse ``--help`` while any one of them was
mid-edit.
Returns:
A JSON-able summary: ``{"episodes": [...], "groups": {...}}`` --
also the exact payload written to ``physics_posterior.json``'s
sibling summary file, and what a caller (or a test) inspects instead
of re-reading every JSON file this function wrote.
"""
import numpy as np
from fpgm.physics.types import RIGID_PARAMS, EpisodeLogLik, ParamSpace, PhysicsError
scene = _physics_import("scene")
materials = _physics_import("materials")
priors_mod = _physics_import("priors")
simulate_mod = _physics_import("simulate")
likelihood_mod = _physics_import("likelihood")
inference_mod = _physics_import("inference")
report_mod = _physics_import("report")
out_root = ensure_dir(Path(out_root))
rng = np.random.default_rng(seed)
# One entry per (scene_id, label): the prior/particles drawn ONCE (see the
# module docstring's "draw particles once" property, inherited from
# fpgm.physics.types) and reused for every episode of that group; per-episode
# PosteriorResults accumulate()-ed against that SAME particle array so
# inference.pool()'s bit-identity check on `particles` never trips.
groups: dict[tuple[str, str], dict[str, Any]] = {}
episode_records: list[dict[str, Any]] = []
for ref in episodes:
scene_id = _scene_id_for(ref)
with _step(timer, "load_poses", uuid=ref.uuid):
tracks, unusable = scene.load_observed_tracks(
ref.uuid, ref.camera_serial, timer=timer,
)
for bad_label, reason in unusable.items():
print(f"[{ref.uuid}/{bad_label}] no usable measured track: {reason}")
if not tracks:
logger.info("%s/%s: no ObservedTrack -- nothing to score", ref.uuid, ref.camera_serial)
continue
for label, track in tracks.items():
with _step(timer, "build_spec", uuid=ref.uuid, obj_label=label):
sim_spec, spec_diag = scene.build_sim_spec(
ref.uuid, ref.camera_serial, label,
scratch_dir=ref.master_dir / "physics" / "scratch", timer=timer,
)
for note in spec_diag.get("limitations", []):
print(f"[{ref.uuid}/{label}] {note}")
body = sim_spec.bodies[0]
key = (scene_id, label)
group = groups.get(key)
if group is None:
space = ParamSpace(RIGID_PARAMS)
if body.kind == "prismatic":
from fpgm.physics.types import PRISMATIC_PARAMS
space = space.extended(PRISMATIC_PARAMS)
try:
with _step(timer, "vlm_prior", uuid=ref.uuid, obj_label=label):
crop_path = scene.object_crop(ref.uuid, ref.camera_serial, label)
verdict = priors_mod.VlmPriorProposer(scene_id=scene_id).propose(
{label: crop_path}, timer=timer
)[label]
except PhysicsError as exc:
verdict = priors_mod.VlmPriorProposer.fallback_verdict(label)
print(
f"[{ref.uuid}/{label}] VLM material prior unavailable ({exc}); "
"using VlmPriorProposer.fallback_verdict"
)
prior = materials.material_prior(verdict, space)
particles = prior.sample(rng, n_particles)
group = groups[key] = {
"space": space, "prior": prior, "particles": particles,
"verdict": verdict, "results": [], "episode_refs": [],
}
with _step(timer, "simulate", uuid=ref.uuid, obj_label=label, n=n_particles):
simulator = simulate_mod.MujocoSimulator(
scratch_dir=ref.master_dir / "physics" / "scratch"
)
sim_result = simulator.simulate_batched(
sim_spec, group["particles"], group["space"], timer=timer
)
with _step(timer, "score", uuid=ref.uuid, obj_label=label, n=n_particles):
loglik = likelihood_mod.log_likelihood(
track, sim_result.poses, sim_result.ok, timer=timer,
)
episode_ll = EpisodeLogLik(
uuid=ref.uuid, camera_serial=ref.camera_serial, label=label,
loglik=loglik, n_obs_frames=track.n_valid,
n_diverged=int(np.sum(~np.asarray(sim_result.ok, dtype=bool))),
)
print(
f"{ref.uuid}/{ref.camera_serial} {label}: spread_nats={episode_ll.spread_nats:.4f} "
f"n_obs_frames={episode_ll.n_obs_frames} n_diverged={episode_ll.n_diverged}"
)
with _step(timer, "accumulate", uuid=ref.uuid, obj_label=label, n=n_particles):
per_episode_posterior = inference_mod.accumulate(
group["prior"], group["particles"], [episode_ll], timer=timer
)
episode_out = ensure_dir(out_root / ref.uuid / ref.camera_serial / label)
with _step(timer, "write_report", uuid=ref.uuid, obj_label=label):
episode_report_path = report_mod.write_object_report(
per_episode_posterior, episode_out,
extra={"uuid": ref.uuid, "camera_serial": ref.camera_serial, "label": label,
"scene_id": scene_id},
)
group["results"].append(per_episode_posterior)
group["episode_refs"].append((ref.uuid, ref.camera_serial))
episode_records.append({
"uuid": ref.uuid, "camera_serial": ref.camera_serial, "scene_id": scene_id,
"label": label, "spread_nats": episode_ll.spread_nats,
"n_obs_frames": episode_ll.n_obs_frames, "n_diverged": episode_ll.n_diverged,
"physics_json": str(episode_report_path),
})
group_summaries: dict[str, Any] = {}
for (scene_id, label), group in groups.items():
with _step(timer, "accumulate", n=len(group["results"])):
pooled = inference_mod.pool(group["results"], timer=timer)
pooled_out = ensure_dir(out_root / f"{scene_id}__{label}")
with _step(timer, "write_report"):
pooled_path = report_mod.write_pooled_report(
pooled, pooled_out,
extra={"scene_id": scene_id, "label": label,
"n_episodes": len(group["results"]),
"episode_ids": [f"{u}/{c}" for u, c in group["episode_refs"]]},
)
group_summaries[f"{scene_id}/{label}"] = {
"n_episodes": len(group["results"]),
"ess": pooled.ess,
"info_nats": pooled.info_nats,
"physics_posterior_json": str(pooled_path),
}
summary = {"episodes": episode_records, "groups": group_summaries}
(out_root / "physics_posterior.json").write_text(json.dumps(summary, indent=2))
return summary
def _physics_import(module_name: str):
"""Import ``fpgm.physics.<module_name>``, or raise a clear, actionable error.
Identical rationale and behaviour to
:meth:`fpgm.datagen.pipeline.EpisodePipeline._physics_import` -- kept as
its own copy here rather than imported from ``fpgm.datagen.pipeline``
because this script has no other reason to depend on the datagen
pipeline package at all, and one three-line helper is cheaper to
duplicate than to couple two otherwise-independent entry points.
"""
import importlib
from fpgm.physics.types import PhysicsError
full_name = f"fpgm.physics.{module_name}"
try:
return importlib.import_module(full_name)
except ImportError as exc:
raise PhysicsError(
f"run_physics_identification requires {full_name} "
f"(src/fpgm/physics/{module_name}.py), which is not importable: {exc}."
) from exc
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--uuid", default=None, help="DROID episode uuid (single-episode mode)")
p.add_argument(
"--camera", default=None,
help="camera serial or role (ext1/ext2); required with --uuid",
)
p.add_argument(
"--from-batch", type=Path, default=None,
help="sweep every episode with S6 output under this batch output_root "
"(e.g. outputs/datagen); mutually exclusive with --uuid/--camera",
)
p.add_argument(
"--particles", type=int, default=_DEFAULT_PARTICLES,
help=f"particles drawn per (scene_id, label) group (default {_DEFAULT_PARTICLES})",
)
p.add_argument("--seed", type=int, default=_DEFAULT_SEED, help="particle-draw RNG seed")
p.add_argument("--out", type=Path, default=_DEFAULT_OUT_ROOT, help="output root")
p.add_argument("--config", type=Path, default=_DEFAULT_CONFIG, help="DatagenProfile YAML")
p.add_argument("--log-level", default="INFO")
args = p.parse_args()
if args.from_batch is None and not (args.uuid and args.camera):
p.error("either --from-batch, or both --uuid and --camera, must be given")
if args.from_batch is not None and (args.uuid or args.camera):
p.error("--from-batch is mutually exclusive with --uuid/--camera")
return args
def _single_episode_ref(args: argparse.Namespace) -> EpisodeRef:
from fpgm.config_datagen import DatagenProfile
profile = DatagenProfile.from_yaml(args.config)
metadata = EpisodeMetadata.from_flows_h5(profile.paths.flows_h5(args.uuid))
camera_serial = metadata.camera_serial(args.camera)
master_dir = profile.paths.master_dir(args.uuid, camera_serial)
return EpisodeRef(args.uuid, camera_serial, master_dir)
def main() -> int:
args = parse_args()
setup_logging(args.log_level)
from fpgm.utils.timing import StepTimer
timer = StepTimer("run_physics_identification")
if args.from_batch is not None:
episodes = discover_episodes_from_batch(args.from_batch)
logger.info("found %d episode(s) with S6 output under %s", len(episodes), args.from_batch)
else:
episodes = [_single_episode_ref(args)]
if not episodes:
print("no episodes to score (0 found) -- nothing written")
print(timer.report())
return 0
summary = run_physics_identification(
episodes, n_particles=args.particles, seed=args.seed, out_root=args.out, timer=timer,
)
out_path = args.out / "physics_posterior.json"
print(
f"\nscored {len(summary['episodes'])} episode-object pair(s) across "
f"{len(summary['groups'])} (scene_id, label) group(s); wrote {out_path}"
)
for key, g in sorted(summary["groups"].items()):
print(
f" {key}: n_episodes={g['n_episodes']} ess={g['ess']:.1f} "
f"info_nats={g['info_nats']:.3f}"
)
print()
print(timer.report())
return 0
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
20 kB
·
Xet hash:
be139cb319fb7020cde258c8f67decfe89beea3d648da28be2458af08fbf18d4

Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.