twanghcmut's picture
download
raw
92.6 kB
"""Assembles a :class:`~fpgm.physics.types.SimSpec`/:class:`~fpgm.physics.types.ObservedTrack`
from artifacts S6/S7 already wrote to disk -- no new perception, no new fitting.
Everything this module reads was already measured by an earlier stage: the object's pose
track and its measured noise floor (S6, ``poses.npz`` / ``object_poses/meta.json``), the
drawer's prismatic axis (S7, ``events.json``, via :class:`fpgm.datagen.events.PrismaticFit`),
the static scene's own observed depth (S3, ``background_depth.h5`` -- see
:func:`_build_heightfield_spec`), and the robot's joint trajectory (DROID's own
``trajectory.h5``). :func:`build_sim_spec` and :func:`build_observed_track`
exist to turn that pile of already-measured artifacts into the two wire-format objects
:mod:`fpgm.physics.worker_mujoco`/``likelihood.py`` need, doing exactly one NEW derivation
along the way -- convex-hull export and finite-difference velocity -- and refusing to
fabricate anything that was not actually measured (see the ``PhysicsError`` raise sites
below, especially the noise floor: it is the one number the whole calibration rests on
being real).
**One non-obvious, EMPIRICALLY VERIFIED fact this module works around**: ``poses.npz``'s
``T_world_obj`` rotation block carries a baked-in *scale* factor for MESH-representation
objects (reconstructed from an arbitrary-CAD-scale ``.glb``, aligned via a scaled SE3), but
NOT for OBSERVED_SURFACE-representation objects (built directly from metric depth points).
Verified directly on the demo episode (``AUTOLab+0d4edc83+2023-10-21-19h-07m-04s``,
``22008760``): the brick's (mesh) rotation-block column norms are ``0.0573`` (its alignment
scale), the drawer's (observed-surface) are ``1.0000`` to 7 decimal places. Both
:class:`~fpgm.physics.types.SimSpec`'s ``BodySpec.init_pose`` and
:mod:`fpgm.physics.worker_mujoco`'s MJCF construction require a PURE rotation (unit-norm
columns; ``_mat_to_quat`` there is not scale-robust) -- so this module always measures the
per-object scale from the anchor frame's own column norms (never assumes 1.0, never assumes
the alignment scale from some other stage) and strips it before writing ``init_pose``,
folding it into the exported convex-hull STL's vertex scale instead so the physical size is
preserved.
**Scenes are multi-body; identification is still single-body.** ``SimSpec.bodies`` is a
list, and :func:`build_sim_spec` now populates it with every object S6 tracked in the
episode -- but :class:`~fpgm.physics.types.SimResult`/``fpgm.physics.worker_mujoco`` still
only ever return (and score) ONE body's trajectory (see that worker's module docstring on
"the body of interest" convention, ``spec.bodies[0]``). ``bodies[0]`` is always the
requested ``label``, a ``"free"``/``"prismatic"`` body whose parameters the returned
:class:`~fpgm.physics.types.ParamPosterior` is actually about. Every OTHER label
``poses.npz`` records for this episode is appended as ``kind="kinematic"`` -- a body driven
exactly along its own measured pose track (:class:`~fpgm.physics.types.BodySpec.pose_track`),
never a hypothesis, never scored on its own. This single change is what makes "the brick
rests on the drawer" fall out of the physics for free: **before** this, the brick's scene
contained no drawer at all, so a brick anchored above the (wrong, independently-fitted)
support plane just fell through empty space to the numerical safety floor (measured on the
demo episode: 59/64 particles diverged, ``ok=False``) -- there was nothing at the drawer's
actual height for it to land on. **After**, the drawer is present at its own measured pose,
so contact with it is possible regardless of which surface S6's plane-fitter happened to
find. No per-episode "brick supports on drawer" logic is written anywhere for this to work;
it is a consequence of both objects' real measured tracks being in the same scene, and it
works symmetrically -- when the drawer is the requested `label`, the brick becomes the
kinematic passenger instead. Only ``bodies[0]``'s parameters are ever inferred by this
stage; a kinematic body's own physical parameters (mass, its own friction independent of
the body of interest) are NOT separately identified -- see
``fpgm.physics.worker_mujoco._apply_kinematic_friction``'s docstring for why that would not
even be identifiable from one episode's evidence.
**Interface assumption -- finger poses via ``RobotModel.link_poses``, not the
``ArmKinematics.fk`` fast path.** ``ArmKinematics.fk``/``link7_to_tcp_transform`` give the
TCP (fingertip-midpoint) pose, not each finger individually -- there is no documented axis
convention anywhere in this codebase for deriving two separate finger-pad poses from that
single TCP pose without guessing which local axis the jaws open along. ``RobotModel.link_poses``
(the same call ``fpgm.robot.kinematics._build_flange_table`` already makes to build
``ArmKinematics``'s own flange-offset table) returns the real URDF ``left_inner_finger`` /
``right_inner_finger`` link poses directly, with no guessed offset. It costs ~0.7 ms/call
(vs. ``ArmKinematics.fk``'s 31 microseconds) -- irrelevant here: S9 builds a spec once per
episode-object offline, not once per IK-solve iteration, so the ~0.7 ms x (frames) cost this
trades away is a rounding error next to the MuJoCo simulation this spec feeds into.
**Where each field is a MEASUREMENT vs. an ESTIMATE vs. a fixed ASSUMPTION** (do not blur
these -- it is the whole point of this stage):
* MEASURED: the object's pose track and its ``pose_noise_mm`` translation noise floor (S6),
the drawer's prismatic axis/origin (S7's ``PrismaticFit``), the static scene's own observed
depth (S3's ``background_depth.h5``), the robot's joint trajectory (DROID), the object's own
convex-hull geometry.
* DERIVED (a defensible computation from a measurement, not a second independent
measurement): initial linear/angular velocity (finite difference of the measured track);
``ObservedTrack.sigma_rot_rad`` (see :func:`_derive_sigma_rot_rad` -- there is no rotational
analogue anywhere in this codebase to ``pose_noise_mm``, so it is derived from the same
measured translation noise via a small-angle lever-arm argument, and that derivation is
never silently presented as an independent measurement); the static-scene heightfield (see
:func:`_build_heightfield_spec` -- a top-down raster of MEASURED depth, not a fit).
* ASSUMED (a fixed, documented modelling choice with no data behind it): the heightfield's
cell size and extent margin (see :data:`_HFIELD_CELL_SIZE_M`,
:data:`_HFIELD_EXTENT_MARGIN_M`), how far below the observed minimum an unobserved cell is
placed (:data:`_HFIELD_HOLE_DROP_M`), the prismatic joint-range padding fraction.
**No more fitted support plane.** Earlier versions of this module gave the scene exactly one
static surface: a plane fitted (by S6/S7, ``fpgm.datagen.geometry_ops.fit_support_plane``)
from ``object_poses/meta.json``'s ``support_plane``. That plane is a fit to wherever an
object's *own* observed footprint happened to sit -- on the demo episode
(``AUTOLab+0d4edc83+2023-10-21-19h-07m-04s``, ``22008760``) that is the brick's *destination*
surface, not the surface it starts on: the brick is measured **+167 mm above** that plane at
its first PNP frame. The table the brick actually starts on was never given collision geometry
at all, so a simulated brick free-fell through empty space to the numerical safety floor
(measured: 59/64 particles diverged, every one ``ok=False``). The table *was* present in the
data -- S3's ``background_depth.h5`` -- it was simply never turned into geometry. That is the
whole bug :func:`_build_heightfield_spec` exists to fix: build ONE static surface from the
observed depth itself, with no object-identity, no plane-fitting, no per-episode judgement
call about which surface is "the" support. See that function's docstring for the heightfield
representation's own honest limitation (no overhangs) and unknown-cell policy (never a
fabricated resting surface).
**Nor a heightfield with a tracked object baked into it.** Fixing the plane bug above
introduced a second, subtler one: ``background_depth.h5`` is S3's TEMPORAL MEDIAN of the
scene, and "static" there means "the median-of-clips merge produced a stable depth value
here" -- NOT "not a tracked object". An object that sits mostly still for the episode is
*itself* what the median depth records at its own footprint, because its own body occludes
whatever is really behind/beneath it from the camera's one viewpoint. On the demo episode
this measurably hurt the DRAWER specifically (it barely moves) while leaving the BRICK
(moves through most of its own footprint) fine: comparing ``outputs/real2sim_demo/summary.json``
(the old fitted-plane run) against a heightfield-only run before the fix below, the brick's
divergence went 58/64 -> 0/64 and its median translation error 133.3 -> 10.5 mm exactly as
expected, but the drawer's median translation error got WORSE, 37.2 -> 64.0 mm --
``build_sim_spec``'s own ``diagnostics["anchor_height_above_heightfield_m"]`` diagnosed it
directly: -11.6 mm for the brick (a plausible, shallow contact resolution) vs. -50.4 mm for
the drawer, over 4x deeper, the signature of a body resting on a copy of itself. Directly
verified (not just inferred from that gap): rasterising with vs. without the drawer's own S5
mask excluded showed 76.2% of the cells under the drawer's own footprint that had ANY
directly-observed support lost that support entirely once the drawer's own pixels were
removed -- almost nothing else was ever visible there across the whole episode. The fix
(:func:`_tracked_object_carve_mask`) carves out every pixel ANY tracked object's own S5 mask
covers, in ANY frame, before rasterising -- keeping the same "no object identity, no
semantics" design intent as the heightfield itself: everything that is not a tracked moving
object becomes one static block, and everything that IS one is excluded regardless of
whether it happens to move in this particular episode. A carved cell is handled by the exact
same :func:`_fill_unknown_cells` unknown-cell policy as any other occlusion -- never
interpolated into a plausible surface, which is also why this raises the heightfield's hole
fraction rather than lowering it.
**Nor a permanent, ANY-frame carve.** The carve above (call it v2; the no-carve heightfield
above it is v1) turned out to be ITS OWN version of the same self-embedding bug, just in the
opposite direction. It unions a tracked object's own mask over EVERY video frame and drops
that whole footprint unconditionally -- but "everywhere an object was ever seen" is a much
bigger region than "where it rests", for any object that actually moves. MEASURED on the
demo episode: the brick moves 206 px mean displacement through the episode, so v2 carved away
not just the brick's body but the TABLE SURFACE it started on and was later moved off of --
a surface plainly visible, cleanly, once the brick is gone. Directly verified: take the
brick's own mask at frame 4 (99 native-resolution background-depth pixels); the SAME 99
pixels are no longer covered by frame 40 (12 of them already revealed) and are fully revealed
by frame 70, staying revealed through frames 100 and 126 -- ``build_sim_spec``'s own
``anchor_height_above_heightfield_m`` diagnosed the damage directly: **+134.5 mm** for the
brick (free-falls the whole episode, never lands) where v1's no-carve heightfield had
(coincidentally -- see above) gotten this particular number approximately right.
The natural next idea -- read ``depth_dense.h5`` (S3's ``(T, H, W)`` per-frame depth) at each
pixel and take a genuine temporal median restricted to the frames a tracked object doesn't
cover it -- turned out to be a red herring, not a fix, once actually checked. MEASURED: at
those same 99 pixels, ``depth_dense.h5``'s value is IDENTICAL to 4+ decimal places across
frames 70/100/126. That is not coincidence: ``fpgm.datagen.dense_depth.compose_dense_depth``
broadcasts ``background_depth.h5`` UNCHANGED across every output frame and then z-buffers a
per-frame ROBOT RENDER on top wherever one is supplied (``source=SPLAT``) -- and DIRECTLY
CHECKED, every ``dense_depth/meta.json`` currently under ``outputs/datagen/`` in this repo has
``robot_composited: false`` (S2's render was never wired into these runs -- "expected for an
S3-only run", per :mod:`fpgm.datagen.dense_depth`'s own docstring). Confirmed at the array
level for the demo episode: ``depth_dense.h5``'s ``depth_mm``/``source`` are BIT-IDENTICAL,
every one of the 127 frames, to ``background_depth.h5`` broadcast across time
(``np.array_equal`` true for every frame checked). So today, across this whole repo,
``depth_dense.h5`` carries ZERO information ``background_depth.h5`` does not already have --
there is no live per-frame measurement of the static surface anywhere in this pipeline, only
ONE measurement (S3's anchor-median plate) replayed at every frame. This also cannot change
for the better by reading more of it: wherever a future run DOES composite a robot render
(``source=SPLAT``), that value is the ROBOT's own rendered depth, not the static scene's --
exactly as untrustworthy as a tracked object's own body (the very bug this section is about),
so it could never legitimately count as a "clean" surface observation either. Building on a
number nobody has actually verified varies would be exactly the mistake "Nor a heightfield
with a tracked object baked into it" above is about -- so :func:`_tracked_object_carve_mask`
never reads ``depth_dense.h5`` at all.
**v3 (this fix): carve only where EVERY frame is covered.** "Median over the frames not
covered by a tracked object" collapses algebraically, given the fact just established, to:
keep ``background_depth.h5``'s own already-measured value at a pixel UNLESS every single
video frame shows some tracked object's mask covering it -- the median of a constant sampled
over any non-empty subset of frames is that same constant, so there is nothing left to
average once the "which frames are clean" question is answered. :func:`_tracked_object_carve_mask`
therefore computes exactly that boolean question from the per-frame masks (union over every
tracked object's own role, per frame; intersection over every frame) and nothing else -- an
excluded pixel has genuinely never once been seen free of a tracked object this episode (the
drawer's own footprint, mostly), and keeps being handled by :func:`_fill_unknown_cells`'s
unchanged unknown-cell policy. A pixel that DOES have at least one clean frame is no longer
excluded at all, regardless of how many other frames a tracked object happened to cover it --
recovering exactly the brick's-starting-table-surface case the ANY-frame carve destroyed,
without reintroducing v1's self-embedding bug for an object (the drawer) that truly is
covered every frame. Both resulting fractions -- cells with no observation anywhere at all,
and cells with an observation that a tracked object nonetheless permanently occludes -- are
reported separately in ``diagnostics`` (``hole_frac_no_observation`` /
``hole_frac_no_clean_observation``), since they call for different responses from a reader.
**MEASURED end-to-end result, this fix, demo episode.** The brick recovers exactly v1's own
``anchor_height_above_heightfield_m`` (-11.6 mm, from +134.5 mm broken under v2) and
``scripts/render_real2sim_demo.py``'s simulated-vs-observed translation error recovers v1's
own 10.5 mm median (from v2's 121.6 mm) -- because literally ZERO native-resolution pixels in
the brick's own footprint are covered in all 127 video frames (it moves too much), this fix's
carve is a complete, measured no-op for the brick specifically, exactly as intended. The
drawer's own ALWAYS-covered fraction is smaller than a naive "the drawer never moves" mental
model predicts -- only 12.2% of its ever-covered VIDEO-resolution pixels are covered in
literally all 127 frames (measured), the rest failing the ALL-frames test by at least one
frame despite the object not physically moving. This is SAM3 mask boundary flicker under
partial robot-arm occlusion, not object motion -- the SAME phenomenon
:mod:`fpgm.datagen.static_span`'s own module docstring already measured and named for this
exact object ("the drawer's SAM mask boundary flickers as the robot arm occludes and reveals
parts of a large fixture"), here showing up as a strict-intersection carve missing most of a
genuinely-static object's own footprint rather than as a noisy centroid. Measured consequence:
the drawer's own simulated translation error (64.0 mm) lands almost exactly on v1's number
(64.0 mm), not v2's slightly better one (60.7 mm) -- this fix trades away a few mm on the
object v2 happened to help in order to stop destroying the object v2 broke by over two orders
of magnitude. A stricter, flicker-robust carve (e.g. a coverage-fraction threshold rather than
a literal ``all()``) is a plausible follow-up but is NOT implemented here: it was not asked
for, has no measured evidence it would improve the actual simulated outcome (only the native-
pixel carve fraction), and this fix already meets the task's own target (the brick no longer
free-falls) without it.
"""
from __future__ import annotations
import contextlib
import json
from pathlib import Path
from typing import Any
import cv2
import h5py
import numpy as np
import trimesh
from fpgm.config_datagen import DatagenProfile
from fpgm.data.droid_raw import read_mp4_properties
from fpgm.data.pointworld import FlowsReader
from fpgm.datagen.frame_index import EpisodeFrameIndex
from fpgm.datagen.object_masks import read_masks_h5
from fpgm.datagen.robot_buffers import (
TRAJECTORY_GRIPPER_POSITION_KEY,
TRAJECTORY_JOINT_POSITIONS_KEY,
load_extrinsics_candidates,
read_native_camera_intrinsics,
)
from fpgm.datagen.types import DepthSourceCode, PoseSource
from fpgm.geometry.camera import Camera
from fpgm.geometry.transforms import matrix_to_rotvec
from fpgm.physics.types import (
BodySpec,
GripperSpec,
HeightfieldSpec,
ObservedTrack,
PhysicsError,
SimSpec,
)
from fpgm.robot.urdf import RobotModel
from fpgm.utils.logging import get_logger
from fpgm.utils.timing import StepTimer
logger = get_logger(__name__)
__all__ = ["build_sim_spec", "build_observed_track", "load_observed_tracks", "object_crop"]
REPO_ROOT = Path(__file__).resolve().parents[3]
_DEFAULT_PROFILE_YAML = REPO_ROOT / "configs" / "datagen_droid.yaml"
#: S6's own "is this frame's pose trustworthy enough to score" gate --
#: ``fpgm.datagen.object_poses.ObjectPoseConfig.visible_frac_gap_threshold``. REUSED, not
#: re-tuned: that value is what S6 itself used to decide whether to demote a PNP frame to
#: GAP (object_poses.py:525, applied at object_poses.py:1590-1592). Duplicated as a literal
#: here (not imported) only because importing ``ObjectPoseConfig`` would pull in the whole
#: S6 estimation module for one float; the value itself is the single source of truth this
#: module trusts, not a re-derivation.
VISIBLE_FRAC_GAP_THRESHOLD = 0.25
#: Heightfield grid vertex spacing, both axes. ASSUMPTION, not measured -- chosen to
#: roughly match the native-resolution background-depth pixel's own footprint at a typical
#: DROID tabletop range. Measured on the demo episode: the background-depth camera's native
#: intrinsics (``fpgm.datagen.robot_buffers.read_native_camera_intrinsics``) give fx ~= 131 px
#: at 320x180, and the observed tabletop sits ~0.9-1.1 m from the camera, so one native pixel
#: already spans roughly (0.9 to 1.1) / 131 ~= 6.9-8.4 mm of world XY there -- 5 mm is close
#: to, and slightly finer than, that native footprint: fine enough not to throw away real
#: structure the depth resolves, without inventing resolution the sensor never had (going
#: much finer would just mean several adjacent cells are fed by the same source pixel).
_HFIELD_CELL_SIZE_M = 0.005
#: How far the heightfield's rasterised XY extent is padded beyond the bounding box of
#: every label ``poses.npz`` tracks in this episode (every frame, not just valid ones --
#: see :func:`_build_heightfield_spec`). ASSUMPTION: generous enough that a typical DROID
#: push/place excursion beyond the tracked bodies' own recorded footprint still lands on
#: real geometry, small enough that the grid stays a local tabletop patch rather than the
#: whole room a background camera happens to see (measured on the demo episode: the raw
#: background-depth point cloud spans a y-range of *4.4 m* end to end -- walls and floor far
#: outside the workspace -- while the tracked bodies' own bounding box is under 30 cm across;
#: rasterising the *whole* observed cloud would build a >300k-cell grid dominated by
#: geometry no simulated body in this episode could ever reach).
_HFIELD_EXTENT_MARGIN_M = 0.35
#: How far below the lowest DIRECTLY OBSERVED cell an unobserved ("hole") cell is placed.
#: ASSUMPTION, not measured. Must be large enough that no plausible object in this stage's
#: scenes (drawer/brick-scale, a few cm) could ever come to rest spanning a hole and a real
#: cell without the discontinuity being obvious in the rollout, small enough that it does
#: not need an unreasonably large `elevation_z` dynamic range on scenes that are mostly
#: holes. See :func:`_build_heightfield_spec`'s docstring for why a hole is dropped rather
#: than interpolated.
_HFIELD_HOLE_DROP_M = 0.15
#: Frames (in chronological PNP-frame order, not necessarily contiguous video-frame
#: indices) used to both anchor the initial pose and finite-difference the initial
#: velocity -- same window size as scripts/settle_after_release.py's own
#: `--n-vel-frames` default.
_DEFAULT_N_VEL_FRAMES = 5
#: Joint-range padding beyond the observed excursion -- a fraction of the observed
#: travel, floored at a fixed minimum so a near-static object's tiny observed range
#: doesn't produce an unrealistically tight prismatic limit. Not measured; a modelling
#: margin so a particle's simulated drawer is not artificially wall-stopped at exactly
#: the historical extremes.
_JOINT_RANGE_PAD_FRAC = 0.2
_JOINT_RANGE_PAD_MIN_M = 0.02
#: Physics substeps per output frame -- overrides SimSpec's dataclass default of 4.
#: Chosen, not measured: `_mujoco_settle_worker.py`-style contact (a rigid body settling
#: onto a support under gravity) was found DURING TESTING to tunnel through a support
#: box at substeps=4 for realistic DROID fall speeds/support thicknesses (see
#: fpgm.physics.worker_mujoco's module docstring for the mechanism -- discrete
#: per-step collision detection, not continuous, can miss a thin support if a body
#: crosses it within one physics step). 40 substeps at a ~1/60 s DROID frame interval
#: gives a ~0.42 ms physics step, comfortably finer than the few-cm support thicknesses
#: and sub-1 m/s object speeds this stage's episodes involve.
DEFAULT_SUBSTEPS = 40
def _load_profile(profile: DatagenProfile | None) -> DatagenProfile:
return profile if profile is not None else DatagenProfile.from_yaml(_DEFAULT_PROFILE_YAML)
def _null_step(timer: StepTimer | None, label: str, **fields: Any):
if timer is None:
return contextlib.nullcontext()
return timer.step(label, **fields)
def _load_master_artifacts(master_dir: Path, label: str) -> dict[str, Any]:
"""Read ``poses.npz``, ``events.json``, ``object_poses/meta.json`` for one label.
Raises:
PhysicsError: If any file is missing, or ``label`` is not tracked in this episode.
"""
poses_path = master_dir / "poses.npz"
events_path = master_dir / "events.json"
meta_path = master_dir / "object_poses" / "meta.json"
for p in (poses_path, events_path, meta_path):
if not p.exists():
raise PhysicsError(f"{label}: required S6/S7 artifact missing: {p}")
with np.load(poses_path) as npz:
labels = [str(x) for x in npz["labels"]]
if label not in labels:
raise PhysicsError(f"{label!r} not in poses.npz labels {labels} ({poses_path})")
T_world_obj = np.asarray(npz[f"{label}__T_world_obj"], dtype=np.float64)
pose_source = np.asarray(npz[f"{label}__pose_source"])
visible_frac = np.asarray(npz[f"{label}__visible_frac"], dtype=np.float64)
events = json.loads(events_path.read_text())
if label not in events:
raise PhysicsError(f"{label!r} not in events.json ({events_path})")
meta = json.loads(meta_path.read_text())
return {
"T_world_obj": T_world_obj,
"pose_source": pose_source,
"visible_frac": visible_frac,
"prismatic": events[label].get("prismatic"),
"meta": meta,
}
def _valid_mask(pose_source: np.ndarray, visible_frac: np.ndarray) -> np.ndarray:
return (pose_source == PoseSource.PNP) & (visible_frac >= VISIBLE_FRAC_GAP_THRESHOLD)
def _pose_noise_mm(meta: dict, label: str) -> float:
objects = meta.get("payload", {}).get("objects", {})
value = objects.get(label, {}).get("pose_noise_mm")
if value is None or not np.isfinite(value) or value <= 0:
raise PhysicsError(
f"{label!r}: no usable measured pose_noise_mm in object_poses/meta.json "
f"(got {value!r}). This is the noise floor the whole likelihood calibration "
"rests on being MEASURED (see fpgm.datagen.object_poses._estimate_pose_noise_mm) "
"-- refusing to substitute a guessed value rather than raise."
)
return float(value)
def _pure_scale(rot: np.ndarray) -> float:
"""Mean column norm of a (3, 3) block -- see module docstring's baked-in-scale note."""
return float(np.mean(np.linalg.norm(rot, axis=0)))
def _canonical_geometry_points(geometry_path: Path, label: str) -> np.ndarray:
"""``(N, 3)`` points in the object's own canonical/local frame, from whichever
representation S6 actually wrote (see ``fpgm.datagen.geometry_ops.write_object_mesh_glb``
/ ``write_object_point_cloud_npz`` -- both representations are handled here, dispatched
purely by file extension since ``object_poses/meta.json``'s ``geometry_paths`` already
points at the right one regardless of which it is).
"""
if geometry_path.suffix == ".glb":
mesh = trimesh.load(str(geometry_path), force="mesh", process=False)
return np.asarray(mesh.vertices, dtype=np.float64)
if geometry_path.suffix == ".npz":
with np.load(geometry_path) as npz:
return np.asarray(npz["points_canonical"], dtype=np.float64)
raise PhysicsError(f"{label}: unrecognised geometry file {geometry_path}")
def _geometry_path(meta: dict, label: str) -> Path:
paths = meta.get("payload", {}).get("geometry_paths", {})
if label not in paths:
raise PhysicsError(f"{label!r}: no geometry_paths entry in object_poses/meta.json")
path = Path(paths[label])
if not path.exists():
raise PhysicsError(f"{label!r}: geometry file listed but missing on disk: {path}")
return path
def _first_pnp_window(pose_source: np.ndarray, n_vel_frames: int) -> np.ndarray:
idx = np.where(pose_source == PoseSource.PNP)[0][:n_vel_frames]
if idx.size < 2:
raise PhysicsError(
f"need >=2 PNP frames to anchor an initial pose/velocity, found {idx.size} "
f"in the first {n_vel_frames} requested"
)
return idx
def _finite_difference_velocity(
T_world_obj: np.ndarray, indices: np.ndarray, scale: float, dt: float
) -> tuple[np.ndarray, np.ndarray]:
"""Linear velocity (least-squares slope) + angular velocity (averaged rotvec/dt),
both WORLD frame -- same method as scripts/settle_after_release.py's own
`estimate_release_velocity`, generalised to a possibly non-contiguous frame window
(unlike that script, S9's PNP frames are not guaranteed consecutive) by using each
consecutive PAIR's own true index gap for its own dt, rather than assuming a fixed
stride.
"""
t = (indices - indices[0]).astype(np.float64) * dt
pos = T_world_obj[indices, :3, 3]
lin_vel = np.array([np.polyfit(t, pos[:, k], 1)[0] for k in range(3)], dtype=np.float64)
omega_samples = []
for i in range(len(indices) - 1):
dt_pair = float(indices[i + 1] - indices[i]) * dt
r0 = T_world_obj[indices[i], :3, :3] / scale
r1 = T_world_obj[indices[i + 1], :3, :3] / scale
r_rel = r1 @ r0.T
omega_samples.append(matrix_to_rotvec(r_rel) / dt_pair)
ang_vel_world = np.mean(np.asarray(omega_samples), axis=0) if omega_samples else np.zeros(3)
return lin_vel, ang_vel_world
def _derive_sigma_rot_rad(sigma_trans_m: float, bounding_radius_m: float) -> float:
"""Small-angle lever-arm estimate: NOT an independent measurement (see module docstring).
No rotational analogue to `pose_noise_mm` exists anywhere in this codebase (S6's
`_estimate_pose_noise_mm` only ever computed a translation residual). Treating the
measured translation noise as if it were a point-tracking error at the object's own
bounding radius from its centroid gives `sigma_rot ~= sigma_trans / radius` -- a
standard order-of-magnitude estimate for a rigid PnP solve, defensible but explicitly
NOT a second measurement, which is why this function exists rather than the derivation
being inlined and blurred into "the" noise floor.
"""
if bounding_radius_m <= 0:
raise PhysicsError(f"non-positive bounding radius {bounding_radius_m} -- degenerate mesh?")
return sigma_trans_m / bounding_radius_m
def _bounding_radius_m(points_canonical: np.ndarray, scale: float) -> float:
"""Max distance from the (scaled) points' own centroid to any point.
Same definition MuJoCo's `geom_rbound` uses internally for a mesh geom (max vertex
distance from the body's own origin) -- recomputed independently here from the
canonical points directly since this module has no MuJoCo/compiled-model access
(fpgm env has no mujoco, see the top-level docstrings on env isolation). The two
numbers are not required to match exactly (they are never compared to each other);
this copy is only used for :func:`_derive_sigma_rot_rad`.
"""
pts = points_canonical * scale
centroid = pts.mean(axis=0)
return float(np.max(np.linalg.norm(pts - centroid, axis=1)))
def _s2_camera(
profile: DatagenProfile, uuid: str, camera_serial: str, master_dir: Path,
video_width: int, video_height: int,
) -> Camera:
"""Reconstruct S2's own VALIDATED camera (intrinsics + extrinsic) for this episode/camera.
Mirrors ``fpgm.datagen.pipeline.EpisodePipeline._run_s6``'s "camera + extrinsics: mirror
S2's own construction" block EXACTLY (same three calls, same order, same source of
truth for which extrinsics candidate won): :func:`~fpgm.datagen.robot_buffers.
read_native_camera_intrinsics` for the intrinsics, :func:`~fpgm.datagen.robot_buffers.
load_extrinsics_candidates` for both extrinsics HYPOTHESES, and S2's own
``robot_buffers/meta.json`` (written by ``run_robot_buffer_stage``) for which of those
two hypotheses actually passed ``RobotAlignmentGate`` (see that class's module
docstring). This is deliberately NOT a re-derivation: S2 measured which extrinsic
candidate agrees with an independent SAM3 detection and which does not (on the demo
episode the two disagree; using the wrong one silently would misplace every unprojected
depth point without any obvious symptom -- exactly the failure mode
``RobotAlignmentGate`` exists to catch). Every downstream stage that unprojects depth
(S6's fixture-geometry build, this function) uses this exact same reconstruction so
there is one, and only one, camera pose for the whole pipeline to ever disagree about.
Returns:
A :class:`~fpgm.geometry.camera.Camera` valid at ``(video_width, video_height)`` --
callers unprojecting a DIFFERENT resolution's depth (e.g. S3's native-resolution
``background_depth.h5``) must call :meth:`~fpgm.geometry.camera.Camera.rescaled`
themselves, same as :func:`fpgm.datagen.geometry_ops._lift_masked_points` already
does for S3's dense depth.
Raises:
PhysicsError: if S2's own ``robot_buffers/meta.json`` is missing (S2 never ran for
this episode/camera) or its recorded ``chosen_extrinsics`` name is not among the
current extrinsics candidates (the candidate set changed since S2 ran) -- this
function never guesses a camera pose S2 itself did not already validate.
"""
s2_meta_path = master_dir / "robot_buffers" / "meta.json"
if not s2_meta_path.exists():
raise PhysicsError(
f"S2 output not found: {s2_meta_path} -- S9 needs the camera extrinsic S2 already "
"validated via RobotAlignmentGate, not a re-derivation"
)
camera_intrinsics_native = read_native_camera_intrinsics(
profile.paths.flows_h5(uuid), camera_serial
)
trajectory_path = profile.paths.episode_dir(uuid) / "trajectory.h5"
extrinsics_candidates = load_extrinsics_candidates(
profile.paths.cameras_json(uuid), trajectory_path, camera_serial
)
s2_meta = json.loads(s2_meta_path.read_text())
chosen_name = s2_meta.get("payload", {}).get("chosen_extrinsics")
chosen = next((c for c in extrinsics_candidates if c.name == chosen_name), None)
if chosen is None:
raise PhysicsError(
f"{s2_meta_path}: chosen_extrinsics={chosen_name!r} is not among the current "
f"extrinsics candidates {[c.name for c in extrinsics_candidates]!r} -- the "
"candidate set changed since S2 ran"
)
return Camera(camera_intrinsics_native.scaled(video_width, video_height), chosen.world_to_cam)
def _load_background_depth(master_dir: Path) -> tuple[np.ndarray, np.ndarray, int, int]:
"""``(depth_mm, source, height, width)`` from S3's ``background_depth.h5``.
Raises:
PhysicsError: file missing, required datasets missing, or a shape/attribute
mismatch -- never silently substitutes a smaller/reshaped array.
"""
path = master_dir / "background_depth.h5"
if not path.exists():
raise PhysicsError(
f"background_depth.h5 not found: {path} -- S3 (background depth) must run "
"before S9 can build a static-scene heightfield"
)
with h5py.File(path, "r") as f:
if "depth_mm" not in f or "source" not in f:
raise PhysicsError(f"{path}: missing depth_mm/source dataset")
depth_mm = np.asarray(f["depth_mm"])
source = np.asarray(f["source"])
height = int(f.attrs.get("height", depth_mm.shape[0] if depth_mm.ndim else -1))
width = int(f.attrs.get("width", depth_mm.shape[1] if depth_mm.ndim == 2 else -1))
if depth_mm.shape != (height, width) or source.shape != (height, width):
raise PhysicsError(
f"{path}: depth_mm/source shape {depth_mm.shape}/{source.shape} disagrees with "
f"recorded (height, width) = ({height}, {width})"
)
return depth_mm, source, height, width
def _rasterize_top_down(
points_xyz: np.ndarray,
*,
cell_size_m: float,
x_range: tuple[float, float],
y_range: tuple[float, float],
) -> tuple[np.ndarray, np.ndarray, np.ndarray, int, int]:
"""Top-down MAX-Z raster of a world-frame point cloud onto a fixed vertex grid.
Pure and data-free (no file I/O, no PhysicsError-worthy business logic beyond a
degenerate-extent check) so it is directly unit-testable with a synthetic point cloud --
see ``tests/test_physics_scene.py``.
**Max, not mean or median.** A falling or sliding body contacts whatever is physically
on TOP within a cell's footprint; a mean or median would blend that top surface with
anything glimpsed through a gap beside/behind it (e.g. a sliver of floor visible past a
table edge at a grazing angle), fabricating a surface height that was never the true
contact height at that cell. Max is also the max ``geom_rbound``-style convention MuJoCo
itself is agnostic to -- the choice is about fidelity to "what does a body dropped here
actually land on", not a MuJoCo requirement.
**Vertex grid, not a pixel grid.** ``nx``/``ny`` are VERTEX counts (``(nx-1)``/``(ny-1)``
cell spans between them), matching MuJoCo's own ``<hfield>`` convention (see
``fpgm.physics.worker_mujoco._heightfield_mujoco_params``'s docstring for the empirically
-verified mapping from ``(row, col)`` to world position) -- a point falling in the half
-open cell ``[x0 + i*cell, x0 + (i+1)*cell)`` is assigned to vertex ``i``, i.e. the LOWER
corner of its cell, not rounded to the nearest vertex. This under- rather than
over-estimates a rising surface's true edge position by up to one cell width, a bias in
the "safe" direction (never claims a body could rest slightly further out than the data
supports).
Args:
points_xyz: ``(N, 3)`` world-frame points, e.g. from unprojecting the background
depth's ANCHOR pixels.
cell_size_m: Uniform vertex spacing, both axes.
x_range, y_range: ``(min, max)`` world extent to rasterise -- points outside are
clipped to the nearest edge vertex column/row rather than dropped, since the
caller (:func:`_build_heightfield_spec`) already restricts this range and a
point landing exactly on the boundary must not be silently lost to a rounding
edge case.
Returns:
``(height_m, valid, origin_xy, nx, ny)``. ``height_m``/``valid`` are ``(ny, nx)``;
``height_m`` is ``NaN`` wherever ``valid`` is ``False`` (zero points fell in that
cell) -- filling that in with a real, non-fabricated sentinel is the CALLER's job
(:func:`_fill_unknown_cells`), kept separate so this rasteriser stays a pure
geometric operation with no "what should an unknown cell be" policy baked in.
Raises:
PhysicsError: if ``x_range``/``y_range`` is degenerate (max <= min).
"""
x0, x1 = x_range
y0, y1 = y_range
if not (x1 > x0 and y1 > y0):
raise PhysicsError(f"degenerate heightfield rasterisation extent: x={x_range} y={y_range}")
points_xyz = np.asarray(points_xyz, dtype=np.float64)
nx = int(np.ceil((x1 - x0) / cell_size_m)) + 1
ny = int(np.ceil((y1 - y0) / cell_size_m)) + 1
ix = np.clip(np.floor((points_xyz[:, 0] - x0) / cell_size_m).astype(np.int64), 0, nx - 1)
iy = np.clip(np.floor((points_xyz[:, 1] - y0) / cell_size_m).astype(np.int64), 0, ny - 1)
flat_idx = iy * nx + ix
height_flat = np.full(nx * ny, -np.inf, dtype=np.float64)
np.maximum.at(height_flat, flat_idx, points_xyz[:, 2])
valid_flat = np.isfinite(height_flat)
height_flat = np.where(valid_flat, height_flat, np.nan)
origin_xy = np.array([x0, y0], dtype=np.float64)
return height_flat.reshape(ny, nx), valid_flat.reshape(ny, nx), origin_xy, nx, ny
def _fill_unknown_cells(height_m: np.ndarray, valid: np.ndarray) -> tuple[np.ndarray, float]:
"""Replace unobserved cells with a fixed sentinel BELOW the observed minimum.
**Why a fixed drop, not interpolation.** Push-pull interpolation is exactly what S3's
own ``background_depth.h5`` already does for ITS holes (see
``fpgm.datagen.background_depth``'s module docstring, step 3) -- and this function
deliberately does NOT reuse that: a smoothed-in pixel is a defensible choice for a video
frame a human or a VLM looks at (a plausible-looking hole beats a visible black gap), but
an invented Z value becoming a simulated RESTING surface is a different, worse failure
mode -- a particle would silently receive contact evidence at a height nobody ever
measured. This module already uses that same reasoning once, upstream of this function
(see :func:`_build_heightfield_spec`: only ``DepthSourceCode.ANCHOR`` pixels are
unprojected at all, never S3's own ``FILLED`` ones). Dropping the sentinel well below
the real minimum instead means: a body that ends up over a hole falls through an honest,
reportable gap (in the worst case reaching the numerical safety floor and coming back
``ok=False``) rather than resting on geometry nobody observed.
Returns:
``(filled_height_m, hole_frac)`` -- ``hole_frac`` is ``1 - valid.mean()``, the
fraction of cells that were unobserved (recorded in
``build_sim_spec``'s ``diagnostics["limitations"]``).
Raises:
PhysicsError: if EVERY cell is unobserved -- nothing to build a floor from at all
(a real capture/extent failure, not something to paper over with an arbitrary
default height).
"""
hole_frac = float(1.0 - valid.mean()) if valid.size else 1.0
if not valid.any():
raise PhysicsError(
"heightfield: zero observed cells anywhere in the rasterised extent -- nothing "
"measured to build a static-scene collision surface from"
)
z_floor = float(np.nanmin(height_m[valid])) - _HFIELD_HOLE_DROP_M
filled = np.where(valid, height_m, z_floor).astype(np.float64)
return filled, hole_frac
#: Half-width, in CELLS, of the neighbourhood :func:`_surface_height_under_xy` medians
#: over. NOT a single-vertex lookup -- see that function's docstring for why: MEASURED
#: directly on the demo episode, one bare vertex sampled at the brick's own footprint
#: centroid read 0.048 m while a median over the brick's whole ~140-cell footprint read
#: 0.195 m, a 15 cm disagreement over one 5 mm cell. That is real per-pixel noise/residual
#: contamination surviving background_depth.py's own per-clip median at an isolated pixel,
#: not a bug in this function -- but it means a single-cell query is not a reliable summary
#: of "the surface here", and a caller asking a human-facing "is this plausible" question
#: needs the more robust neighbourhood statistic instead.
_SURFACE_QUERY_HALF_WINDOW_CELLS = 3
def _surface_height_under_xy(
height_m: np.ndarray, valid: np.ndarray, origin_xy: np.ndarray, cell_size_m: float,
xy: np.ndarray,
) -> float | None:
"""MEDIAN heightfield sample in a small neighbourhood under a world ``(x, y)`` -- a
diagnostic lookup, not what the simulator itself uses (MuJoCo bilinearly interpolates
the FULL grid; this is a coarse, human-facing "is this plausible" summary, never fed
into the physics).
**Median over a window, not a single nearest vertex.** See
:data:`_SURFACE_QUERY_HALF_WINDOW_CELLS`'s docstring for the measured reason: at this
module's 5 mm cell size, ONE cell can disagree with its neighbours by well over a
centimetre (real sensor noise/an isolated contaminated pixel surviving S3's own
per-clip merge), so a bare nearest-vertex lookup is not a representative "what does the
data say the surface height is here" answer. A window median is: individual noisy
cells get outvoted, while an unobserved region still correctly returns ``None`` rather
than silently substituting a distant cell's value (see below).
Returns:
The median height over the ``(2*half_window+1)^2``-cell window centred on the
nearest vertex to ``xy``, restricted to cells where ``valid`` is ``True`` (the
ORIGINAL observed mask, not the hole-filled array -- so this can never report a
fabricated hole height as if it meant something). ``None`` if ``xy`` falls outside
the grid entirely, or if EVERY cell in the window is unobserved.
"""
ny, nx = height_m.shape
ix = int(round((float(xy[0]) - float(origin_xy[0])) / cell_size_m))
iy = int(round((float(xy[1]) - float(origin_xy[1])) / cell_size_m))
if not (0 <= ix < nx and 0 <= iy < ny):
return None
hw = _SURFACE_QUERY_HALF_WINDOW_CELLS
x0, x1 = max(0, ix - hw), min(nx, ix + hw + 1)
y0, y1 = max(0, iy - hw), min(ny, iy + hw + 1)
window_valid = valid[y0:y1, x0:x1]
if not window_valid.any():
return None
return float(np.median(height_m[y0:y1, x0:x1][window_valid]))
def _tracked_object_carve_mask(
camera_dir: Path, native_h: int, native_w: int,
) -> tuple[np.ndarray, dict[str, Any]]:
"""Pixels, at S3's background-depth NATIVE resolution, that get NO clean (object-free)
observation in ANY video frame -- the cells :func:`_build_heightfield_spec` must treat as
unknown rather than trust ``background_depth.h5``'s value at.
**v1 -> v2 -> v3.** See this module's own docstring ("Nor a heightfield with a tracked
object baked into it" / "Nor a permanent, ANY-frame carve") for the full history and the
measurements behind each step; summary: v1 (no carve at all) let a mostly-still tracked
object (measured here: the drawer) become its own resting surface. v2 (carve every pixel
covered in ANY frame, permanently) overcorrected -- a MOVING tracked object (measured
here: the brick, 206 px mean displacement) carved away real, later-revealed surface it
once merely passed over, including the table it started on (MEASURED:
``anchor_height_above_heightfield_m`` = +134.5 mm, free-falls). v3 (this function) carves
a pixel only if EVERY frame shows a tracked object covering it -- equivalent to a
per-frame temporal median restricted to clean frames (see the module docstring for why
that reduces to exactly this boolean question, and why ``depth_dense.h5`` is deliberately
never read to compute it).
**Combine rule: union over ROLES per frame, intersection over FRAMES.** For each video
frame, a pixel is "covered" if ANY tracked object's own S5 mask covers it that frame (no
object identity, no semantics -- same as v2: every label
:class:`~fpgm.datagen.object_masks.PromptMaskStage` segmented for this episode/camera
counts, whether or not it is the body of interest, a kinematic passenger, or one that
never actually moved). A pixel is carved (returned ``True``) only if it is "covered" in
EVERY frame -- i.e. ``combined_covered_video.all(axis=0)``, not ``.any(axis=0)`` (that
would be v2 again). The union-over-roles step must happen BEFORE the intersection-over-
frames step (not the reverse) because a pixel can be permanently occluded by the UNION of
two objects taking turns covering it without either one covering it alone in every frame
-- see this function's own tests for a concrete case.
**Label mapping: read, never guessed.** The masks live at
``camera_dir/master/prompt_<object_id>_masks.h5`` where ``object_id`` is an internal
enumeration index with no semantic meaning on its own -- the only trustworthy
``{label: masks_path}`` mapping is ``camera_dir/prompt_masks/meta.json``'s own
``payload["roles"]`` list, written by :class:`~fpgm.datagen.object_masks.PromptMaskStage.
run` and already reused for exactly this purpose by
``fpgm.datagen.pipeline.EpisodePipeline._collect_gates`` (``prompt_meta_path =
master_dir.parent / "prompt_masks" / "meta.json"``). This function reads the identical
file the same way rather than inventing a second label-recovery heuristic -- e.g. assuming
``object_id`` order agrees with ``poses.npz``'s own label order, which happens to be true
for the demo episode but is not guaranteed by anything.
Args:
camera_dir: ``profile.paths.camera_dir(uuid, camera_serial)`` -- the directory
``master/`` and ``prompt_masks/`` both live under.
native_h, native_w: S3's ``background_depth.h5`` native resolution -- the resolution
the returned mask is returned at, since it is ANDed directly against
``background_depth.h5``'s own ``source`` array in :func:`_build_heightfield_spec`.
The intersection-over-time reduction happens FIRST, at the SAM3 video resolution
(a ``(H, W)`` array); the resize to native resolution happens once afterwards,
nearest-neighbour (never bilinear/area, same convention
:func:`fpgm.datagen.geometry_ops._lift_masked_points` already uses) -- so the
resize cost is independent of ``T``, same as v2 had it.
Returns:
``(carve_mask, diagnostics)``. ``carve_mask`` is ``(native_h, native_w)`` bool,
all-``False`` in the degenerate case every role's own mask was unreadable.
``diagnostics`` carries ``n_roles_total``, ``roles_carved`` (labels successfully
combined), ``roles_skipped`` (``{label: reason}`` for a role whose own mask file could
not be read -- an artifact-unreadable case, not a filtering decision), the
intersection carve's own ``carve_frac_native`` (fraction of the native grid this
function excludes), and ``ever_covered_frac_native`` (what v2's rejected ANY-frame
carve would have excluded instead -- kept only as a documented point of comparison;
always ``>= carve_frac_native``, with equality exactly when every carved pixel's
covering object(s) never once revealed it, e.g. the demo episode's drawer).
Raises:
PhysicsError: If ``camera_dir/prompt_masks/meta.json`` itself is missing, corrupt, or
lists zero roles -- there is then no reliable label mapping to carve from at all,
and this function refuses to guess one (e.g. by falling back to the unlabelled
S4/S5 discovery output, :class:`~fpgm.datagen.object_masks.ObjectMaskStage`'s
``object_masks`` stage, whose ``object_id`` carries no semantic label -- see that
module's own "S4+S5 replacement" section for why that path was superseded).
"""
meta_path = camera_dir / "prompt_masks" / "meta.json"
if not meta_path.exists():
raise PhysicsError(
f"heightfield carve: {meta_path} not found -- S9 needs the label -> own-S5-mask "
"mapping fpgm.datagen.object_masks.PromptMaskStage wrote to know which pixels "
"belong to a TRACKED OBJECT rather than the static scene; refusing to guess that "
"mapping from object_id order or fall back to the unlabelled motion-clustering "
"(object_masks) stage instead"
)
try:
meta = json.loads(meta_path.read_text())
except json.JSONDecodeError as exc:
raise PhysicsError(f"heightfield carve: {meta_path} is corrupt: {exc}") from exc
roles = meta.get("payload", {}).get("roles", [])
if not roles:
raise PhysicsError(
f"heightfield carve: {meta_path} lists zero roles -- no label mapping to carve from"
)
combined_covered_video: np.ndarray | None = None # (T, H, W) bool, OR over every role.
roles_carved: list[str] = []
roles_skipped: dict[str, str] = {}
for role in roles:
label = str(role.get("label") or f"<unlabelled object_id={role.get('object_id')}>")
masks_path_str = role.get("masks_path")
if not masks_path_str:
roles_skipped[label] = "role has no masks_path in prompt_masks/meta.json"
continue
masks_path = Path(masks_path_str)
if not masks_path.exists():
roles_skipped[label] = f"own S5 mask file missing on disk: {masks_path}"
continue
try:
masks = read_masks_h5(masks_path) # (T, H, W) bool, SAM3 video resolution.
except OSError as exc:
roles_skipped[label] = f"own S5 mask file unreadable: {exc}"
continue
if combined_covered_video is None:
combined_covered_video = masks.copy()
elif masks.shape != combined_covered_video.shape:
roles_skipped[label] = (
f"own S5 mask shape {masks.shape} disagrees with another already-combined "
f"role's {combined_covered_video.shape} -- cannot union onto one grid"
)
continue
else:
combined_covered_video |= masks
roles_carved.append(label)
if combined_covered_video is None:
carve_native = np.zeros((native_h, native_w), dtype=bool)
ever_covered_frac_native = 0.0
else:
always_covered_video = combined_covered_video.all(axis=0) # this fix's carve.
# v2's rejected ANY-frame carve, kept only as a documented comparison diagnostic.
ever_covered_video = combined_covered_video.any(axis=0)
carve_native = cv2.resize(
always_covered_video.astype(np.uint8), (native_w, native_h),
interpolation=cv2.INTER_NEAREST,
).astype(bool)
ever_covered_native = cv2.resize(
ever_covered_video.astype(np.uint8), (native_w, native_h),
interpolation=cv2.INTER_NEAREST,
).astype(bool)
ever_covered_frac_native = (
float(ever_covered_native.mean()) if ever_covered_native.size else 0.0
)
diagnostics: dict[str, Any] = {
"n_roles_total": len(roles),
"roles_carved": roles_carved,
"roles_skipped": roles_skipped,
"carve_frac_native": float(carve_native.mean()) if carve_native.size else 0.0,
"ever_covered_frac_native": ever_covered_frac_native,
}
return carve_native, diagnostics
def _build_heightfield_spec(
profile: DatagenProfile,
uuid: str,
camera_serial: str,
master_dir: Path,
scratch_dir: Path,
*,
video_width: int,
video_height: int,
timer: StepTimer | None = None,
) -> tuple[HeightfieldSpec, dict[str, Any]]:
"""The scene's single static collision surface, built from S3's observed depth.
Replaces the old fitted-``support_plane`` entirely (see this module's docstring for the
measured bug that motivated the change) -- see
:class:`~fpgm.physics.types.HeightfieldSpec`'s own docstring for the representation's
honest limitation (no overhangs) and the unknown-cell policy (never fabricated).
Pipeline, each step delegated to a pure, independently-tested helper:
1. Load S3's ``background_depth.h5`` (:func:`_load_background_depth`).
2. Keep ONLY ``DepthSourceCode.ANCHOR`` pixels -- i.e. pixels a real merged clip anchor
covered, per :mod:`fpgm.datagen.background_depth`'s own median-of-anchors merge --
and drop every ``FILLED`` (push-pull interpolated) pixel. This is a SECOND,
independent "do not trust an already-invented value" decision on top of
:func:`_fill_unknown_cells`'s own -- S3's fill is fine for a video frame, not for
collision geometry a body might rest on (see that function's docstring).
3. Also exclude every pixel that gets NO clean (object-free) observation in ANY video
frame (:func:`_tracked_object_carve_mask`) -- ``background_depth.h5`` is a TEMPORAL
MEDIAN, so "static" there does not mean "not a tracked object": an object that sits
mostly still (measured here: the drawer) is itself what the median depth records at
its own footprint, and simulating it would then mean colliding it with a baked-in copy
of itself. This is deliberately NOT a permanent ANY-frame carve (that was tried and
measured to break a MOVING tracked object instead -- see this module's own docstring's
"Nor a permanent, ANY-frame carve" section, and that function's own docstring, for the
full history and the depth-provenance investigation that motivated this).
4. Reconstruct S2's validated camera (:func:`_s2_camera`) and unproject the surviving
pixels to world-frame points.
5. Restrict the rasterised extent to a margin around every label ``poses.npz`` tracks in
this episode (every frame, valid or not -- extent sizing is not a quality gate) --
NOT the raw point cloud's own extent, which spans a background camera's whole
field of view (measured on the demo episode: ~4.4 m of world Y, dominated by a wall/
floor no simulated body in this episode's rollout could ever reach). See
:data:`_HFIELD_EXTENT_MARGIN_M`.
6. Rasterise (:func:`_rasterize_top_down`) and hole-fill (:func:`_fill_unknown_cells`) --
the carve in step 3 means a permanently-occluded pixel now hole-fills exactly like any
other occluded region, per that function's own unknown-cell policy (never a fabricated
resting surface). A SECOND, diagnostic-only rasterisation over the pre-carve point set
(never written to ``heightfield.npz``, never fed to the simulator) then splits
``hole_frac`` into its two structurally different causes -- see the ``Returns`` entry
below.
7. Write ``height_m``/``valid`` to ``scratch_dir/heightfield.npz`` (see
:class:`~fpgm.physics.types.HeightfieldSpec`'s docstring for why a sidecar file, not
inline JSON).
Returns:
``(spec, diagnostics)`` -- ``diagnostics`` carries ``hole_frac`` (fraction of cells
in the final grid that were NOT directly observed), split into
``hole_frac_no_observation`` (cells with zero ANCHOR-sourced support anywhere, at ANY
time, independent of any tracked object -- a capture-coverage limitation) and
``hole_frac_no_clean_observation`` (cells that DO have ANCHOR-sourced support
somewhere, but every one of those pixels is covered by a tracked object in every
single frame -- this stage's own honest occlusion residual; on the demo episode, the
drawer's own never-revealed footprint). ``hole_frac == hole_frac_no_observation +
hole_frac_no_clean_observation`` always (the post-carve point set is a strict subset
of the pre-carve one, so a cell the carve empties was necessarily non-empty before
it). Also carries ``n_anchor_points``, ``n_cells``, the rasterised ``extent_m``, and
``carve`` (:func:`_tracked_object_carve_mask`'s own diagnostics -- which labels were
combined, which were skipped and why, and the native-resolution carve fraction).
Raises:
PhysicsError: missing/malformed ``background_depth.h5``, no finite tracked-body
position to size the extent from, no usable ``prompt_masks/meta.json`` label
mapping to carve from (see :func:`_tracked_object_carve_mask`), zero ANCHOR
pixels surviving the carve within the extent, or (degenerate) zero valid cells
anywhere in the rasterised grid -- see the helpers above for exactly which
condition raises which message.
"""
with _null_step(timer, "heightfield_extent", uuid=uuid):
with np.load(master_dir / "poses.npz") as npz:
labels = [str(x) for x in npz["labels"]]
xy_by_label = [np.asarray(npz[f"{lbl}__T_world_obj"])[:, :2, 3] for lbl in labels]
all_xy = np.concatenate(xy_by_label, axis=0) if xy_by_label else np.zeros((0, 2))
all_xy = all_xy[np.all(np.isfinite(all_xy), axis=1)]
if all_xy.shape[0] == 0:
raise PhysicsError(
"heightfield: no finite tracked-body position anywhere in poses.npz to size "
"the heightfield's extent from"
)
x_range = (
float(all_xy[:, 0].min()) - _HFIELD_EXTENT_MARGIN_M,
float(all_xy[:, 0].max()) + _HFIELD_EXTENT_MARGIN_M,
)
y_range = (
float(all_xy[:, 1].min()) - _HFIELD_EXTENT_MARGIN_M,
float(all_xy[:, 1].max()) + _HFIELD_EXTENT_MARGIN_M,
)
with _null_step(timer, "heightfield_unproject", uuid=uuid):
depth_mm, source, native_h, native_w = _load_background_depth(master_dir)
camera = _s2_camera(
profile, uuid, camera_serial, master_dir,
video_width=video_width, video_height=video_height,
).rescaled(native_w, native_h)
anchor_mask = source == DepthSourceCode.ANCHOR
n_anchor_precarve = int(anchor_mask.sum())
if n_anchor_precarve == 0:
raise PhysicsError(
"heightfield: zero ANCHOR-sourced pixels anywhere in background_depth.h5 -- "
"nothing directly measured to build a collision surface from, regardless of "
"any tracked-object carve"
)
with _null_step(timer, "heightfield_carve_objects", uuid=uuid):
carve_native, carve_diag = _tracked_object_carve_mask(
profile.paths.camera_dir(uuid, camera_serial), native_h, native_w,
)
carve_diag["n_anchor_px_precarve"] = n_anchor_precarve
carve_diag["n_anchor_px_postcarve"] = int((anchor_mask & ~carve_native).sum())
# PRE-carve pixel set (every ANCHOR pixel, whether or not a tracked object ever
# covers it) is unprojected ONCE; the carve is then applied as a boolean select on
# these same points below, so the hole_frac_no_observation / hole_frac_no_clean_
# observation split (see this function's own docstring) needs no second unprojection.
ys, xs = np.nonzero(anchor_mask)
carved_here = carve_native[ys, xs]
uv = np.stack([xs + 0.5, ys + 0.5], axis=1).astype(np.float64)
depth_m = depth_mm[ys, xs].astype(np.float64) / 1000.0
points_world = camera.unproject(uv, depth_m)
in_extent = (
(points_world[:, 0] >= x_range[0]) & (points_world[:, 0] <= x_range[1]) &
(points_world[:, 1] >= y_range[0]) & (points_world[:, 1] <= y_range[1])
)
points_world = points_world[in_extent]
carved_in_extent = carved_here[in_extent]
if points_world.shape[0] == 0:
raise PhysicsError(
"heightfield: zero ANCHOR-sourced background points fall within the "
f"tracked-body extent x={x_range} y={y_range} -- nothing to rasterise"
)
points_world_clean = points_world[~carved_in_extent]
if points_world_clean.shape[0] == 0:
raise PhysicsError(
"heightfield: every ANCHOR-sourced background point within the tracked-body "
f"extent is covered by a tracked object in EVERY frame (roles carved: "
f"{carve_diag['roles_carved']}) -- nothing directly measured and ever "
"object-free to build a collision surface from"
)
with _null_step(timer, "heightfield_rasterize", uuid=uuid, n=int(points_world_clean.shape[0])):
height_m, valid, origin_xy, nx, ny = _rasterize_top_down(
points_world_clean, cell_size_m=_HFIELD_CELL_SIZE_M, x_range=x_range, y_range=y_range,
)
height_m, hole_frac = _fill_unknown_cells(height_m, valid)
grid_path = scratch_dir / "heightfield.npz"
np.savez(grid_path, height_m=height_m, valid=valid)
with _null_step(timer, "heightfield_hole_diagnostics", uuid=uuid, n=int(points_world.shape[0])):
# Diagnostic-only: rasterise the PRE-carve point set onto the SAME grid to learn which
# holes are "never observed at all" vs. "observed, but a tracked object permanently
# occludes it" -- see this function's own docstring for the exact accounting identity.
# Never written to heightfield.npz, never influences the simulator.
_height_no_carve, valid_no_carve, _origin2, _nx2, _ny2 = _rasterize_top_down(
points_world, cell_size_m=_HFIELD_CELL_SIZE_M, x_range=x_range, y_range=y_range,
)
hole_frac_no_observation = (
float(1.0 - valid_no_carve.mean()) if valid_no_carve.size else 1.0
)
hole_frac_no_clean_observation = float(np.mean(valid_no_carve & ~valid))
spec = HeightfieldSpec(
grid_path=grid_path, nx=nx, ny=ny, cell_size_m=_HFIELD_CELL_SIZE_M, origin_xy=origin_xy,
)
diagnostics: dict[str, Any] = {
"n_anchor_points_in_extent": int(points_world_clean.shape[0]),
"n_cells": int(nx * ny),
"hole_frac": hole_frac,
"hole_frac_no_observation": hole_frac_no_observation,
"hole_frac_no_clean_observation": hole_frac_no_clean_observation,
"extent_m": {"x": list(x_range), "y": list(y_range)},
"cell_size_m": _HFIELD_CELL_SIZE_M,
"carve": carve_diag,
}
return spec, diagnostics
def _prismatic_kind_and_range(
prismatic: dict | None, T_world_obj: np.ndarray, valid: np.ndarray, anchor_idx: int
) -> tuple[str, np.ndarray | None, np.ndarray | None, tuple[float, float]]:
"""``(kind, axis_world, origin_world, joint_range)``.
`joint_range` is expressed relative to the SAME zero-point as the simulation's own
qpos=0 reference (the anchor frame's world pose, see build_sim_spec) -- NOT relative
to `PrismaticFit.origin_world` (its PCA centroid) and NOT indexed by
`PrismaticFit.displacement` directly, because that array is only defined over the
fit's own (gated, `visible_frac`-filtered) frame subset, whose indices do not align
1:1 with the full video-frame axis `valid`/`anchor_idx` are indexed by (measured
directly: on the demo episode, `n_frames` in the gated fit is 101 of 127 total video
frames). The axis/origin ARE reused as-is (measured by
`fpgm.datagen.events.PrismaticFit`, never re-fit here); only the displacement is
recomputed, as a plain projection of the already-tracked `T_world_obj` onto that
same measured axis, over every valid frame -- not a second measurement of the axis
itself.
"""
if prismatic is None or not prismatic.get("is_prismatic", False):
return "free", None, None, (-1.0, 1.0)
axis_world = np.asarray(prismatic["axis_world"], dtype=np.float64)
origin_world = np.asarray(prismatic["origin_world"], dtype=np.float64)
axis_world = axis_world / np.linalg.norm(axis_world)
full_displacement = (T_world_obj[:, :3, 3] - origin_world[None, :]) @ axis_world
valid_disp = full_displacement[valid]
if valid_disp.size == 0:
return "prismatic", axis_world, origin_world, (-1.0, 1.0)
lo, hi = float(valid_disp.min()), float(valid_disp.max())
anchor_disp = float(full_displacement[anchor_idx])
pad = max(_JOINT_RANGE_PAD_MIN_M, _JOINT_RANGE_PAD_FRAC * (hi - lo))
joint_range = (lo - anchor_disp - pad, hi - anchor_disp + pad)
return "prismatic", axis_world, origin_world, joint_range
def _finger_geometry(robot: RobotModel) -> np.ndarray:
"""``(3,)`` half-extents for both finger-pad mocap boxes -- MEASURED from the URDF's
own finger-pad visual mesh AABB (in each link's own local frame), not a guessed
constant. Both `left_inner_finger`/`right_inner_finger` are measured and averaged
(component-wise) since the Robotiq 2F-85 pads are nominally mirror-symmetric; any
residual difference is small numerical/authoring noise in the URDF meshes.
"""
meshes = robot.visual_meshes()
half_extents = []
for link_name in ("left_inner_finger", "right_inner_finger"):
if link_name not in meshes:
raise PhysicsError(f"URDF has no visual geometry for {link_name!r}")
pts = []
for mesh, transform in meshes[link_name]:
verts = np.asarray(mesh.vertices, dtype=np.float64)
verts_link = (transform[:3, :3] @ verts.T).T + transform[:3, 3]
pts.append(verts_link)
pts = np.concatenate(pts, axis=0)
half_extents.append((pts.max(axis=0) - pts.min(axis=0)) / 2.0)
return np.mean(np.asarray(half_extents), axis=0)
def build_observed_track(
uuid: str,
camera_serial: str,
label: str,
*,
profile: DatagenProfile | None = None,
timer: StepTimer | None = None,
) -> ObservedTrack:
"""The measured pose track + measured noise floor for one episode-object.
Args:
uuid, camera_serial, label: Which episode/camera/object -- must already have a
completed S6 run (``poses.npz``, ``object_poses/meta.json``).
profile: Defaults to ``configs/datagen_droid.yaml`` (the same profile every real
S6 caller uses -- see ``fpgm.datagen.pipeline``).
timer: See module-level Timing note; ``None`` is a no-op.
Raises:
PhysicsError: Missing artifact, unknown label, or (see module docstring)
unavailable measured noise floor -- never substitutes a guess.
"""
profile = _load_profile(profile)
master_dir = profile.paths.master_dir(uuid, camera_serial)
with _null_step(timer, "load_observed_track", uuid=uuid, body_label=label):
art = _load_master_artifacts(master_dir, label)
T_world_obj = art["T_world_obj"]
pose_source = art["pose_source"]
visible_frac = art["visible_frac"]
meta = art["meta"]
valid = _valid_mask(pose_source, visible_frac)
sigma_trans_m = _pose_noise_mm(meta, label) / 1000.0
anchor_idx = int(_first_pnp_window(pose_source, _DEFAULT_N_VEL_FRAMES)[-1])
scale = _pure_scale(T_world_obj[anchor_idx, :3, :3])
geometry_path = _geometry_path(meta, label)
points_canonical = _canonical_geometry_points(geometry_path, label)
bounding_radius_m = _bounding_radius_m(points_canonical, scale)
sigma_rot_rad = _derive_sigma_rot_rad(sigma_trans_m, bounding_radius_m)
episode_dir = profile.paths.episode_dir(uuid)
mp4_path = episode_dir / "recordings" / "MP4" / f"{camera_serial}.mp4"
mp4_fps, n_video_frames, _res = read_mp4_properties(mp4_path)
if n_video_frames != T_world_obj.shape[0]:
raise PhysicsError(
f"{label}: poses.npz has {T_world_obj.shape[0]} frames but the mp4 reports "
f"{n_video_frames} -- video-frame-axis artifacts have gone out of sync"
)
return ObservedTrack(
uuid=uuid, camera_serial=camera_serial, label=label,
T_world_obj=T_world_obj, valid=valid,
sigma_trans_m=sigma_trans_m, sigma_rot_rad=sigma_rot_rad, fps=float(mp4_fps),
)
def load_observed_tracks(
uuid: str,
camera_serial: str,
*,
profile: DatagenProfile | None = None,
labels: list[str] | None = None,
timer: StepTimer | None = None,
) -> tuple[dict[str, ObservedTrack], dict[str, str]]:
"""Every object in this episode that has a usable measured track.
:func:`build_observed_track` is deliberately single-object and strict -- it
raises rather than substituting a guessed noise floor. That strictness is
right for one object and wrong for an episode: one object whose mesh is
missing must not cost the caller the other object in the same clip.
So this wrapper keeps the strictness and downgrades it to a *per-object*
outcome, returning the objects that loaded plus a ``{label: reason}`` map
for the ones that did not. The reasons are returned, never logged-and
-dropped, because "S9 found no objects here" and "S9 found two objects and
both failed for the same missing-mesh reason" call for completely different
responses, and only the second one is actionable.
Note what this is *not*: a quality filter. Nothing here inspects how much
the object moved, and nothing may -- an object that sat still is loaded
exactly like one that was thrown across the table, and the arithmetic in
:mod:`fpgm.physics.inference` makes the still one a no-op on its own. The
only thing that excludes an object here is the artifact being unreadable.
Args:
uuid, camera_serial: Which episode/camera.
profile: Defaults to ``configs/datagen_droid.yaml``.
labels: Restrict to these labels. ``None`` (default) takes every label
``poses.npz`` records, which is what makes this work unchanged for
motion-discovered objects (``obj0``/``obj1``...) that no instruction
ever named.
timer: See module-level Timing note; ``None`` is a no-op.
Returns:
``(tracks, skipped)``.
Raises:
PhysicsError: Only if ``poses.npz`` itself is missing/unreadable -- that
is an episode-level fault (S6 never ran), not an object-level one.
"""
profile = _load_profile(profile)
master_dir = profile.paths.master_dir(uuid, camera_serial)
poses_path = master_dir / "poses.npz"
if not poses_path.exists():
raise PhysicsError(f"{uuid}/{camera_serial}: no S6 output at {poses_path}")
if labels is None:
with np.load(poses_path) as npz:
labels = [str(x) for x in npz["labels"]]
tracks: dict[str, ObservedTrack] = {}
skipped: dict[str, str] = {}
with _null_step(timer, "load_observed_tracks", uuid=uuid, n=max(len(labels), 1)):
for label in labels:
try:
tracks[label] = build_observed_track(
uuid, camera_serial, label, profile=profile, timer=timer
)
except PhysicsError as exc:
skipped[label] = str(exc)
logger.warning("S9: %s/%s object %r unusable: %s", uuid, camera_serial, label, exc)
return tracks, skipped
def object_crop(
uuid: str,
camera_serial: str,
label: str,
*,
profile: DatagenProfile | None = None,
) -> Path:
"""Path to an RGB crop of ``label``, for the VLM material read.
The VLM needs a picture of the object, and S5 already wrote one per object
per episode while building its masks. Regenerating a crop here would mean
re-decoding the mp4 and re-reading the mask h5 to produce an image that is
already on disk, so this only locates it.
Deliberately raises instead of returning ``None`` when nothing is found: the
caller's fallback (a material-agnostic prior) is a real, recorded downgrade
in the report, and it should be reached through the same ``PhysicsError``
path as every other VLM failure rather than through a quietly-empty return
that looks like success.
Raises:
PhysicsError: If no crop exists for this object.
"""
profile = _load_profile(profile)
master_dir = profile.paths.master_dir(uuid, camera_serial)
for pattern in (f"crops/{label}.png", f"crops/{label}_*.png", f"{label}_crop.png"):
hits = sorted(master_dir.glob(pattern))
if hits:
return hits[0]
raise PhysicsError(
f"{uuid}/{camera_serial}: no crop image for object {label!r} under {master_dir}/crops -- "
"S5 writes one per object; a missing crop means the VLM cannot see this object"
)
def _kinematic_body_spec(
master_dir: Path, label: str, n_frames_video: int, scratch_dir: Path,
) -> tuple[BodySpec, list[str]]:
"""One OTHER tracked object as a ``kind="kinematic"`` :class:`BodySpec`.
Reuses exactly the artifacts/derivations :func:`build_sim_spec` already uses for the
body of interest -- :func:`_load_master_artifacts`, :func:`_canonical_geometry_points`,
the convex-hull export, :func:`_pure_scale` -- so there is exactly one place in this
module that knows how to turn an S6 pose track + geometry file into a MuJoCo-ready
body, not two subtly-diverging copies.
**Scale, from a single frame, not a window.** The body of interest's ``init_pose``
measures scale from the LAST frame of its first-PNP *velocity* window (see
:func:`_first_pnp_window`) because that same window also finite-differences an initial
velocity. A kinematic body needs neither a velocity nor a single "start" frame -- its
whole ``pose_track`` is used -- so it only needs ONE trustworthy (PNP-sourced) frame,
anywhere in the episode, to measure its own baked-in scale (see the module docstring's
measured mesh-vs-observed-surface fact) and its hull. The first PNP-sourced frame is
used for that, deliberately not averaged across several: the baked-in scale is a fixed
per-object constant (a CAD-alignment factor, or exactly 1.0), not a noisy per-frame
quantity, so a second frame would not reduce any variance -- it would only add a
dependency on that frame also being well-tracked.
**Untrustworthy frames.** Frames that fail S6's own gate (:func:`_valid_mask`: not
PNP-sourced, or below ``visible_frac_gap_threshold``) are NOT held out of the returned
``pose_track`` -- MuJoCo needs a pose at every output frame for a mocap body, there is
no "skip this frame" affordance. Substituting a fabricated pose (extrapolated, or
silently reusing whatever ``poses.npz`` already interpolated/gap-filled there) would let
an unmeasured guess drive contact geometry as if it were data. The choice made here
instead: hold the body at its *nearest measured* pose (forward-filled from the last
valid frame; back-filled from the first valid frame for any run of invalid frames at
the very start) -- honestly stale rather than fabricated, and the number of frames this
happened to is always returned so a caller can judge whether that staleness could
plausibly matter for this particular episode.
Returns:
``(body, limitations)`` -- ``limitations`` is empty unless some frames needed
holding at a stale pose (see above), in which case it names exactly how many.
Raises:
PhysicsError: If this object's own S6 artifacts cannot support a kinematic body at
all -- missing geometry, a video-frame-axis mismatch, zero PNP-sourced frames
to measure a scale from, or (degenerate) zero frames passing S6's own gate even
though a PNP frame existed. The caller (:func:`build_sim_spec`) downgrades this
into a per-object skip + a recorded diagnostic, the same discipline
:func:`load_observed_tracks` already applies to a whole :class:`ObservedTrack`.
"""
art = _load_master_artifacts(master_dir, label)
T_world_obj = art["T_world_obj"]
pose_source = art["pose_source"]
visible_frac = art["visible_frac"]
meta = art["meta"]
if T_world_obj.shape[0] != n_frames_video:
raise PhysicsError(
f"{label}: has {T_world_obj.shape[0]} pose frames, but the primary object's "
f"video has {n_frames_video} -- video-frame-axis artifacts have gone out of sync"
)
valid = _valid_mask(pose_source, visible_frac)
pnp_idx = np.where(pose_source == PoseSource.PNP)[0]
if pnp_idx.size == 0:
raise PhysicsError(f"{label}: no PNP-sourced frame at all -- cannot measure a scale/hull")
scale = _pure_scale(T_world_obj[int(pnp_idx[0]), :3, :3])
geometry_path = _geometry_path(meta, label)
points_canonical = _canonical_geometry_points(geometry_path, label)
points_scaled = points_canonical * scale
hull = trimesh.Trimesh(vertices=points_scaled, process=False).convex_hull
hull_stl = scratch_dir / f"{label}_hull.stl"
hull.export(str(hull_stl))
pose_track = T_world_obj.copy()
pose_track[:, :3, :3] = T_world_obj[:, :3, :3] / scale
# Translation is untouched: the module docstring's measured fact is that the baked-in
# alignment scale lives ONLY in the rotation block's column norms, never in position.
limitations: list[str] = []
n_untrusted = int((~valid).sum())
if n_untrusted:
if not valid.any():
raise PhysicsError(
f"{label}: 0 of {pose_track.shape[0]} frames pass S6's own PNP+visibility "
"gate even though a PNP-sourced frame existed for scale -- refusing to build "
"a kinematic body whose entire track would be fabricated"
)
first_good_idx = int(np.argmax(valid))
pose_track[:first_good_idx] = pose_track[first_good_idx]
last_good = pose_track[first_good_idx].copy()
for t in range(first_good_idx + 1, pose_track.shape[0]):
if valid[t]:
last_good = pose_track[t].copy()
else:
pose_track[t] = last_good
limitations.append(
f"{label}: {n_untrusted}/{pose_track.shape[0]} frames had an untrustworthy pose "
"(not PNP-sourced, or below visible_frac_gap_threshold) and were held at the "
"nearest MEASURED pose (forward-filled, back-filled only before the first valid "
"frame) rather than treated as new measurements -- this kinematic body's motion "
"is stale, not observed, on those frames"
)
body = BodySpec(
label=label, mesh_path=hull_stl, init_pose=pose_track[0], kind="kinematic",
pose_track=pose_track,
)
return body, limitations
def build_sim_spec(
uuid: str,
camera_serial: str,
label: str,
*,
profile: DatagenProfile | None = None,
scratch_dir: Path,
substeps: int = DEFAULT_SUBSTEPS,
n_vel_frames: int = _DEFAULT_N_VEL_FRAMES,
timer: StepTimer | None = None,
) -> tuple[SimSpec, dict[str, Any]]:
"""Build a multi-body :class:`~fpgm.physics.types.SimSpec` for one episode-object.
``spec.bodies[0]`` is ``label``, built exactly as before (a ``"free"`` or
``"prismatic"`` body -- the one whose parameters this stage identifies). Every OTHER
label ``poses.npz`` records for this episode is then appended as a ``kind="kinematic"``
passenger, driven along its own measured pose track -- see the module docstring's
"Scenes are multi-body" section for why (in one sentence: so the object actually
supporting/contacting ``label`` is present in the scene at all, without this module
ever having to know or guess which tracked object that is).
Args:
uuid, camera_serial, label: Which episode/camera/object to build a spec for.
profile: Defaults to ``configs/datagen_droid.yaml``.
scratch_dir: Where every body's convex-hull STL is written -- ``label``'s and every
kinematic passenger's alike (``SimSpec``/``BodySpec`` carry a path, not mesh
bytes -- see ``BodySpec.mesh_path``'s docstring). Caller-owned; not cleaned up
here, since :class:`~fpgm.physics.simulate.MujocoSimulator` needs the files to
still exist when the worker subprocess reads them.
substeps: See :data:`DEFAULT_SUBSTEPS`.
n_vel_frames: See :data:`_DEFAULT_N_VEL_FRAMES`.
timer: See module-level Timing note; ``None`` is a no-op.
Returns:
``(spec, diagnostics)`` -- ``diagnostics`` records every ASSUMED/DERIVED choice
made along the way (heightfield hole fraction, prismatic joint range, measured
scale, kinematic-body count, limitations) so a caller can put it straight into a
``StageCache`` payload without re-deriving why a particular spec looks the way it
does. A kinematic passenger being unreadable (missing geometry, no PNP-sourced
frame to measure scale from) never raises -- it is dropped and recorded in
``diagnostics["limitations"]``, the same discipline :func:`load_observed_tracks`
already applies; only ``label`` itself (``bodies[0]``) being unreadable raises.
Raises:
PhysicsError: Missing artifact for ``label`` itself, unknown ``label``, or a
video-frame-axis/trajectory mismatch (never silently degrades to a
smaller/misaligned track).
"""
profile = _load_profile(profile)
scratch_dir = Path(scratch_dir)
scratch_dir.mkdir(parents=True, exist_ok=True)
master_dir = profile.paths.master_dir(uuid, camera_serial)
limitations: list[str] = []
with _null_step(timer, "load_artifacts", uuid=uuid, body_label=label):
art = _load_master_artifacts(master_dir, label)
T_world_obj = art["T_world_obj"]
pose_source = art["pose_source"]
visible_frac = art["visible_frac"]
meta = art["meta"]
n_frames = T_world_obj.shape[0]
valid = _valid_mask(pose_source, visible_frac)
with _null_step(timer, "frame_index", uuid=uuid, n=n_frames):
episode_dir = profile.paths.episode_dir(uuid)
trajectory_path = episode_dir / "trajectory.h5"
if not trajectory_path.exists():
raise PhysicsError(f"{label}: trajectory.h5 not found: {trajectory_path}")
mp4_path = episode_dir / "recordings" / "MP4" / f"{camera_serial}.mp4"
if not mp4_path.exists():
raise PhysicsError(f"{label}: mp4 not found: {mp4_path}")
mp4_fps, n_video_frames, video_wh = read_mp4_properties(mp4_path)
if n_video_frames != n_frames:
raise PhysicsError(
f"{label}: poses.npz has {n_frames} frames but the mp4 reports "
f"{n_video_frames} -- video-frame-axis artifacts have gone out of sync"
)
dt = 1.0 / float(mp4_fps)
with h5py.File(trajectory_path, "r") as f:
joint_positions = np.asarray(f[TRAJECTORY_JOINT_POSITIONS_KEY])
gripper_signal = np.asarray(f[TRAJECTORY_GRIPPER_POSITION_KEY])
flows_path = profile.paths.flows_h5(uuid)
with FlowsReader(flows_path, episode_uuid=uuid) as reader:
frame_index = EpisodeFrameIndex.build(
uuid, reader, trajectory_path,
mp4_properties=(mp4_fps, n_video_frames, video_wh), camera_serial=camera_serial,
)
with _null_step(timer, "convex_hull", uuid=uuid, body_label=label):
anchor_idx = int(_first_pnp_window(pose_source, n_vel_frames)[-1])
anchor_rot = T_world_obj[anchor_idx, :3, :3]
scale = _pure_scale(anchor_rot)
r_pure = anchor_rot / scale
geometry_path = _geometry_path(meta, label)
points_canonical = _canonical_geometry_points(geometry_path, label)
points_scaled = points_canonical * scale
hull = trimesh.Trimesh(vertices=points_scaled, process=False).convex_hull
hull_stl = scratch_dir / f"{label}_hull.stl"
hull.export(str(hull_stl))
logger.info(
"%s: convex hull %d verts/%d faces (scale=%.6f, from %d canonical points at %s)",
label, hull.vertices.shape[0], hull.faces.shape[0], scale,
points_canonical.shape[0], geometry_path.name,
)
with _null_step(timer, "init_state", uuid=uuid, body_label=label):
vel_window = _first_pnp_window(pose_source, n_vel_frames)
lin_vel_world, ang_vel_world = _finite_difference_velocity(
T_world_obj, vel_window, scale, dt
)
init_pose = np.eye(4, dtype=np.float64)
init_pose[:3, :3] = r_pure
init_pose[:3, 3] = T_world_obj[anchor_idx, :3, 3]
kind, axis_world, origin_world, joint_range = _prismatic_kind_and_range(
art["prismatic"], T_world_obj, valid, anchor_idx
)
body = BodySpec(
label=label, mesh_path=hull_stl, init_pose=init_pose,
init_lin_vel=lin_vel_world, init_ang_vel=ang_vel_world,
kind=kind, joint_axis_world=axis_world, joint_origin_world=origin_world,
joint_range=joint_range,
)
with _null_step(timer, "heightfield", uuid=uuid):
heightfield, heightfield_diag = _build_heightfield_spec(
profile, uuid, camera_serial, master_dir, scratch_dir,
video_width=video_wh[0], video_height=video_wh[1], timer=timer,
)
if heightfield_diag["hole_frac"] > 0:
limitations.append(
f"heightfield: {heightfield_diag['hole_frac'] * 100:.1f}% of "
f"{heightfield_diag['n_cells']} rasterised cells had zero directly-observed "
f"support and were set {_HFIELD_HOLE_DROP_M:.2f} m below the observed "
"minimum (see fpgm.physics.scene._fill_unknown_cells) -- a body cannot rest "
"on those cells; it will fall through to an honest divergence instead. Of "
f"that, {heightfield_diag['hole_frac_no_observation'] * 100:.1f}pp was never "
f"observed at all (independent of any tracked object) and "
f"{heightfield_diag['hole_frac_no_clean_observation'] * 100:.1f}pp WAS "
"observed but a tracked object covers it in every single frame (see "
"fpgm.physics.scene._tracked_object_carve_mask)."
)
for skipped_label, reason in heightfield_diag["carve"]["roles_skipped"].items():
limitations.append(
f"heightfield carve: {skipped_label!r} was NOT excluded from the static "
f"surface ({reason}) -- if this object sits mostly still, its own body may "
"still be baked into the heightfield at its own footprint (see "
"fpgm.physics.scene._tracked_object_carve_mask's docstring)."
)
with _null_step(timer, "finger_poses", uuid=uuid, n=n_frames):
robot = RobotModel(str(profile.paths.urdf), load_meshes=True)
finger_size = _finger_geometry(robot)
finger_poses = np.empty((n_frames, 2, 4, 4), dtype=np.float64)
for vf in range(n_frames):
row = frame_index.trajectory_row(vf)
g = float(gripper_signal[row])
poses = robot.link_poses(joint_positions[row], g)
finger_poses[vf, 0] = poses["left_inner_finger"]
finger_poses[vf, 1] = poses["right_inner_finger"]
gripper = GripperSpec(finger_poses=finger_poses, finger_size=finger_size)
with np.load(master_dir / "poses.npz") as npz:
all_labels = [str(x) for x in npz["labels"]]
other_labels = [lbl for lbl in all_labels if lbl != label]
kinematic_bodies: list[BodySpec] = []
with _null_step(timer, "kinematic_bodies", uuid=uuid, n=max(len(other_labels), 1)):
for other_label in other_labels:
try:
kbody, klimitations = _kinematic_body_spec(
master_dir, other_label, n_frames, scratch_dir,
)
except PhysicsError as exc:
limitations.append(
f"{other_label}: dropped as a kinematic body in {label}'s scene ({exc})"
)
logger.warning(
"%s/%s: dropping kinematic body %r from %r's scene: %s",
uuid, camera_serial, other_label, label, exc,
)
continue
kinematic_bodies.append(kbody)
limitations.extend(klimitations)
spec = SimSpec(
uuid=uuid, camera_serial=camera_serial, dt=dt, n_frames=n_frames,
bodies=[body, *kinematic_bodies], gripper=gripper, heightfield=heightfield,
substeps=substeps,
)
with np.load(heightfield.grid_path) as hz:
anchor_surface_z = _surface_height_under_xy(
np.asarray(hz["height_m"]), np.asarray(hz["valid"]), heightfield.origin_xy,
heightfield.cell_size_m, init_pose[:3, 3][:2],
)
anchor_height_above_heightfield_m = None
if anchor_surface_z is not None:
anchor_height_above_heightfield_m = float(init_pose[2, 3] - anchor_surface_z)
if anchor_height_above_heightfield_m > 0.05:
limitations.append(
f"anchor frame ({anchor_idx}) is {anchor_height_above_heightfield_m:.3f} m "
"above the observed heightfield surface directly under it -- the object is "
"not resting near the static scene surface at simulation start (e.g. it has "
"not been placed there yet in the episode); a free body will free-fall this "
"whole distance before any contact is possible, which can exhaust n_frames or "
"hit the numerical safety floor (ok=False) before ever reaching a surface. "
"This is not a bug: it is an honest consequence of always anchoring at the "
"FIRST PNP frames (see module docstring) regardless of which episode phase "
"the surface below is relevant to."
)
else:
limitations.append(
f"anchor frame ({anchor_idx})'s (x, y) falls on an UNOBSERVED heightfield cell -- "
"no directly-measured surface height is available directly under the object at "
"simulation start (see fpgm.physics.scene._surface_height_under_xy)."
)
diagnostics: dict[str, Any] = {
"kind": kind,
"scale": scale,
"bounding_hull_n_vertices": int(hull.vertices.shape[0]),
"n_kinematic_bodies": len(kinematic_bodies),
"anchor_frame_idx": anchor_idx,
"vel_window_frame_idx": vel_window.tolist(),
"init_lin_speed_mps": float(np.linalg.norm(lin_vel_world)),
"init_ang_speed_radps": float(np.linalg.norm(ang_vel_world)),
"joint_range_m": list(joint_range) if kind == "prismatic" else None,
"heightfield": heightfield_diag,
"anchor_height_above_heightfield_m": anchor_height_above_heightfield_m,
"mp4_fps": float(mp4_fps),
"substeps": substeps,
"sim_dt_s": dt / substeps,
"limitations": limitations,
}
return spec, diagnostics

Xet Storage Details

Size:
92.6 kB
·
Xet hash:
af3bf7482ccd361d785b7643ea6559df06f101d1ade569e0e988128b713f952f

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