Buckets:
| #!/usr/bin/env python | |
| """Physically settle the brick after release, instead of leaving it hanging. | |
| --- Why this exists --------------------------------------------------------- | |
| The rest of this pipeline is deliberately *kinematic*: no mass, no friction, | |
| no inertia -- a grasp is a rigid attachment and a release just... stops | |
| updating the object's pose (see scripts/plan_pick_place_books.py: | |
| ``object_poses[t, 0] = object_poses[release_frame_idx, 0]`` for every frame | |
| after release). That is fine everywhere the arm is in control of the object, | |
| but it produces a visible defect at the one moment it is not: the book | |
| surface sits at the very edge of the arm's reach and IK there only converges | |
| with the wrist pitched 40-55 deg off vertical (see that script's docstring), | |
| so the rigidly-attached brick is released tilted 37.26 deg from flat, and | |
| then -- because "released" currently just means "keep the last pose forever" | |
| -- it hangs there, its far edge floating ~5 cm above the books, forever. | |
| Physically the brick would tip and fall flat. This script is the one place | |
| genuine rigid-body physics is warranted, and it is confined to exactly the | |
| post-release window: everything before ``release_frame_idx`` is left | |
| untouched (bit-identical), and only the released trajectory is replaced by | |
| a simulated fall-and-settle, resampled back onto the plan's own 15 Hz clock. | |
| --- Why MuJoCo lives in its own conda env, not `fpgm` ----------------------- | |
| `pyproject.toml` pins ``numpy<2`` -- a hard constraint inherited from SAM | |
| 3.1's own dependencies, protecting the working SAM3/TAPNext/pyrender stack | |
| already installed in the ``fpgm`` env. A plain ``pip install mujoco`` pulls | |
| numpy 2.x and risks a downgrade cascade there. So, exactly like | |
| ``scripts/trellis_generate.py`` (TRELLIS.2, its own torch) and | |
| ``scripts/sam3d_generate.py`` (SAM 3D Objects, its own torch) before it: a | |
| small, dependency-light worker (``scripts/_mujoco_settle_worker.py``, mujoco | |
| + numpy only, no fpgm import -- that env doesn't have fpgm installed) runs in | |
| an isolated ``mujoco`` env, invoked here as a subprocess and handed JSON | |
| files rather than shared in-process objects. This script also never imports | |
| ``mujoco`` itself and the worker never imports ``mujoco.viewer`` -- this host | |
| has no hardware OpenGL (software OSMesa only); only MuJoCo's physics | |
| stepping is used anywhere in this pipeline, never its renderer. | |
| --- Why mass is not a parameter to agonise over ------------------------------ | |
| A rigid body's free-fall-and-settle trajectory under gravity, contact, and | |
| friction is mass-INDEPENDENT: Newton's second law (F = ma) has gravity | |
| contributing a force proportional to m, contact normal/friction forces that | |
| (for a rigid, non-deformable contact model) also scale with the normal load | |
| which itself scales with m, and there is no other force in this scene (no | |
| motors, no air drag) that does not scale with m -- so m cancels out of the | |
| equations of motion entirely. This is checked empirically at the bottom of | |
| this script (``--check-mass-independence``, on by default): the whole | |
| simulation is re-run at 10x the mass and the resting pose is confirmed | |
| essentially unchanged. What DOES matter, and is NOT free, is friction and | |
| the contact solver's softness (which stands in for restitution -- MuJoCo has | |
| no literal coefficient-of-restitution attribute; bounciness is purely a | |
| function of how underdamped ``solref`` is). Those are recorded plainly in | |
| the output sidecar as ASSUMPTIONS, never as measurements. | |
| Usage: | |
| PYTHONPATH=src /home/quang/miniconda3/envs/fpgm/bin/python \\ | |
| scripts/settle_after_release.py \\ | |
| --npz outputs/pick_place_books/pick_place_books.npz \\ | |
| --out outputs/pick_place_books/pick_place_books_settled.npz \\ | |
| --out-json outputs/pick_place_books/pick_place_books_settled.json | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import importlib.util | |
| import json | |
| import subprocess | |
| import sys | |
| import tempfile | |
| import time | |
| from pathlib import Path | |
| import numpy as np | |
| import trimesh | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(REPO_ROOT / "src")) | |
| from fpgm.geometry.transforms import matrix_to_rotvec # noqa: E402 | |
| from fpgm.utils.logging import get_logger, setup_logging # noqa: E402 | |
| # Same importlib trick used throughout this repo (see e.g. | |
| # scripts/render_counterfactual_push.py's docstring): `sys.modules[spec.name]` | |
| # MUST be set before `exec_module`, or `run_object_pipeline.py`'s own | |
| # `@dataclass`-decorated classes fail isinstance checks / pickling. | |
| _spec = importlib.util.spec_from_file_location( | |
| "run_object_pipeline", REPO_ROOT / "scripts" / "run_object_pipeline.py" | |
| ) | |
| rop = importlib.util.module_from_spec(_spec) | |
| sys.modules[_spec.name] = rop | |
| _spec.loader.exec_module(rop) | |
| logger = get_logger("settle_after_release") | |
| _TRAJ_FPS = 15.0 | |
| _DEFAULT_MUJOCO_PYTHON = Path("/home/quang/miniconda3/envs/mujoco/bin/python") | |
| _WORKER_SCRIPT = REPO_ROOT / "scripts" / "_mujoco_settle_worker.py" | |
| #: Friction is a genuine ASSUMPTION (never measured for this brick/book | |
| #: contact) -- (sliding, torsional, rolling), MuJoCo's own convention. 0.5 | |
| #: sliding friction is a generic "dry, moderately rough" value (comparable to | |
| #: wood-on-wood or cardboard-on-paper); the torsional/rolling values are | |
| #: MuJoCo's own defaults, left alone because this is a corner-tipping brick, | |
| #: not a rolling object, so they barely matter here. | |
| _ASSUMED_FRICTION = (0.5, 0.005, 0.0001) | |
| #: Restitution is likewise an ASSUMPTION: MuJoCo has no literal | |
| #: coefficient-of-restitution attribute, bounciness is entirely a function of | |
| #: how underdamped the contact constraint is. solref=(0.02, 1) is MuJoCo's | |
| #: own default -- a critically damped contact, i.e. essentially NO bounce, | |
| #: which is the physically reasonable assumption for a small, light brick | |
| #: dropped from ~8 mm (not a superball). | |
| _ASSUMED_SOLREF = (0.02, 1.0) | |
| _ASSUMED_SOLIMP = (0.9, 0.95, 0.001, 0.5, 2.0) | |
| # --------------------------------------------------------------------------- # | |
| # Standalone quaternion <-> matrix helpers. | |
| # | |
| # Deliberately NOT shared with scripts/_mujoco_settle_worker.py (which uses | |
| # mujoco.mju_mat2Quat/mju_quat2Mat directly): that worker runs in a separate | |
| # conda env and this side of the fence has no mujoco import to reuse, so the | |
| # handful of lines below are duplicated rather than adding a cross-env | |
| # dependency for them. fpgm.geometry.transforms deliberately has no quaternion | |
| # helpers (see its docstring: axis-angle only, to pin down one convention) so | |
| # this is new, not a reinvention of an existing fpgm util. | |
| # --------------------------------------------------------------------------- # | |
| def _mat_to_quat_wxyz(rot: np.ndarray) -> np.ndarray: | |
| """(3, 3) proper rotation matrix -> (4,) unit quaternion (w, x, y, z). | |
| Standard branch-on-largest-diagonal-term (Shepperd's method) for numerical | |
| stability near all rotation angles, including near pi. | |
| """ | |
| m = np.asarray(rot, dtype=np.float64) | |
| tr = m[0, 0] + m[1, 1] + m[2, 2] | |
| if tr > 0: | |
| s = np.sqrt(tr + 1.0) * 2.0 | |
| w = 0.25 * s | |
| x = (m[2, 1] - m[1, 2]) / s | |
| y = (m[0, 2] - m[2, 0]) / s | |
| z = (m[1, 0] - m[0, 1]) / s | |
| elif m[0, 0] > m[1, 1] and m[0, 0] > m[2, 2]: | |
| s = np.sqrt(1.0 + m[0, 0] - m[1, 1] - m[2, 2]) * 2.0 | |
| w = (m[2, 1] - m[1, 2]) / s | |
| x = 0.25 * s | |
| y = (m[0, 1] + m[1, 0]) / s | |
| z = (m[0, 2] + m[2, 0]) / s | |
| elif m[1, 1] > m[2, 2]: | |
| s = np.sqrt(1.0 + m[1, 1] - m[0, 0] - m[2, 2]) * 2.0 | |
| w = (m[0, 2] - m[2, 0]) / s | |
| x = (m[0, 1] + m[1, 0]) / s | |
| y = 0.25 * s | |
| z = (m[1, 2] + m[2, 1]) / s | |
| else: | |
| s = np.sqrt(1.0 + m[2, 2] - m[0, 0] - m[1, 1]) * 2.0 | |
| w = (m[1, 0] - m[0, 1]) / s | |
| x = (m[0, 2] + m[2, 0]) / s | |
| y = (m[1, 2] + m[2, 1]) / s | |
| z = 0.25 * s | |
| q = np.array([w, x, y, z], dtype=np.float64) | |
| return q / np.linalg.norm(q) | |
| def _quat_wxyz_to_mat(quat: np.ndarray) -> np.ndarray: | |
| """(4,) unit quaternion (w, x, y, z) -> (3, 3) proper rotation matrix.""" | |
| w, x, y, z = np.asarray(quat, dtype=np.float64) | |
| n = w * w + x * x + y * y + z * z | |
| s = 2.0 / n if n > 0 else 0.0 | |
| wx, wy, wz = s * w * x, s * w * y, s * w * z | |
| xx, xy, xz = s * x * x, s * x * y, s * x * z | |
| yy, yz, zz = s * y * y, s * y * z, s * z * z | |
| return np.array( | |
| [ | |
| [1.0 - (yy + zz), xy - wz, xz + wy], | |
| [xy + wz, 1.0 - (xx + zz), yz - wx], | |
| [xz - wy, yz + wx, 1.0 - (xx + yy)], | |
| ] | |
| ) | |
| def _slerp(q0: np.ndarray, q1: np.ndarray, t: float) -> np.ndarray: | |
| """Spherical linear interpolation between two wxyz unit quaternions.""" | |
| q0 = q0 / np.linalg.norm(q0) | |
| q1 = q1 / np.linalg.norm(q1) | |
| dot = float(np.dot(q0, q1)) | |
| if dot < 0.0: | |
| q1 = -q1 | |
| dot = -dot | |
| dot = min(dot, 1.0) | |
| if dot > 0.9995: | |
| out = q0 + t * (q1 - q0) | |
| return out / np.linalg.norm(out) | |
| theta0 = np.arccos(dot) | |
| theta = theta0 * t | |
| q2 = q1 - q0 * dot | |
| q2 = q2 / np.linalg.norm(q2) | |
| return q0 * np.cos(theta) + q2 * np.sin(theta) | |
| def _plane_basis(normal: np.ndarray) -> tuple[np.ndarray, np.ndarray]: | |
| """Orthonormal in-plane axes, identical convention to | |
| ``scripts/plan_pick_place_books.py``'s ``_plane_basis`` (world +X | |
| projected onto the plane, falling back to +Y), so "in-plane offset" | |
| numbers reported by this script are directly comparable to that script's. | |
| """ | |
| normal = normal / np.linalg.norm(normal) | |
| seed = np.array([1.0, 0.0, 0.0]) if abs(normal[0]) < 0.9 else np.array([0.0, 1.0, 0.0]) | |
| u_hat = seed - normal * np.dot(seed, normal) | |
| u_hat = u_hat / np.linalg.norm(u_hat) | |
| v_hat = np.cross(normal, u_hat) | |
| return u_hat, v_hat | |
| def _rest_up_axis_canonical(t_obj_rest: np.ndarray) -> np.ndarray: | |
| """Which mesh-canonical axis was "up" in the object's original rest pose. | |
| Same idea as ``plan_pick_place_books.py``'s | |
| ``_rest_up_axis_in_tcp_frame`` (whichever local axis has the largest | |
| world-Z component in the as-scanned rest pose, sign-corrected) but | |
| expressed directly in world/canonical terms rather than TCP-local, since | |
| this script has no TCP in the loop. Used purely as the fixed reference | |
| direction for "tilt from flat" so it is comparable, frame-for-frame, to | |
| the ``tilt_from_flat_deg=37.26`` figure already in the plan's sidecar | |
| (verified to reproduce that number to 0.001 deg before this script was | |
| written). | |
| """ | |
| r = t_obj_rest[:3, :3] | |
| scale = float(np.mean(np.linalg.norm(r, axis=0))) | |
| r_pure = r / scale | |
| up_idx = int(np.argmax(np.abs(r_pure[2, :]))) | |
| sign = np.sign(r_pure[2, up_idx]) or 1.0 | |
| e_up = np.zeros(3, dtype=np.float64) | |
| e_up[up_idx] = sign | |
| return e_up | |
| def tilt_from_flat_deg(r_pure: np.ndarray, e_up: np.ndarray, normal: np.ndarray) -> float: | |
| up_world = r_pure @ e_up | |
| up_world = up_world / np.linalg.norm(up_world) | |
| return float(np.degrees(np.arccos(np.clip(np.dot(up_world, normal), -1.0, 1.0)))) | |
| # --------------------------------------------------------------------------- # | |
| # Velocity inheritance: finite-difference the last few attached frames. | |
| # --------------------------------------------------------------------------- # | |
| def estimate_release_velocity( | |
| object_poses: np.ndarray, release_frame_idx: int, n_frames: int, scale: float | |
| ) -> tuple[np.ndarray, np.ndarray]: | |
| """Linear + angular velocity of the brick at the moment of release. | |
| "The brick inherits the gripper's velocity at release" -- estimated by | |
| finite-differencing the brick's own recorded world poses over the | |
| ``n_frames`` samples immediately before (and including) the release | |
| frame, at the plan's 15 Hz clock. Linear velocity: a least-squares slope | |
| (more robust to per-frame FK noise than a single last-step difference). | |
| Angular velocity: the world-frame rotation-vector increment between each | |
| consecutive pair in the window, divided by dt, averaged -- returned in | |
| WORLD frame (the caller converts to MuJoCo's body-local qvel convention). | |
| """ | |
| lo = max(0, release_frame_idx - (n_frames - 1)) | |
| idx = np.arange(lo, release_frame_idx + 1) | |
| if idx.size < 2: | |
| raise ValueError( | |
| f"need >=2 frames to estimate release velocity, got {idx.size} " | |
| f"(release_frame_idx={release_frame_idx}, n_frames={n_frames})" | |
| ) | |
| dt = 1.0 / _TRAJ_FPS | |
| t = (idx - idx[0]).astype(np.float64) * dt | |
| pos = object_poses[idx, :3, 3] | |
| lin_vel = np.array([np.polyfit(t, pos[:, k], 1)[0] for k in range(3)], dtype=np.float64) | |
| r_pure = object_poses[idx, :3, :3] / scale | |
| omega_samples = [] | |
| for i in range(len(idx) - 1): | |
| r_rel = r_pure[i + 1] @ r_pure[i].T | |
| omega_samples.append(matrix_to_rotvec(r_rel) / dt) | |
| ang_vel_world = np.mean(np.asarray(omega_samples), axis=0) if omega_samples else np.zeros(3) | |
| return lin_vel, ang_vel_world | |
| # --------------------------------------------------------------------------- # | |
| # MuJoCo subprocess plumbing | |
| # --------------------------------------------------------------------------- # | |
| def _run_worker( | |
| mujoco_python: Path, | |
| hull_stl: Path, | |
| scale: float, | |
| release_pos: np.ndarray, | |
| release_quat: np.ndarray, | |
| lin_vel_world: np.ndarray, | |
| ang_vel_body: np.ndarray, | |
| mass_kg: float, | |
| friction: tuple[float, float, float], | |
| solref: tuple[float, float], | |
| solimp: tuple[float, float, float, float, float], | |
| surface_pos: np.ndarray, | |
| surface_quat: np.ndarray, | |
| surface_half_extent: np.ndarray, | |
| floor_z: float, | |
| sim_dt: float, | |
| max_sim_time: float, | |
| record_dt: float, | |
| settle_lin_vel_thresh: float, | |
| settle_ang_vel_thresh: float, | |
| settle_consecutive_steps: int, | |
| scratch_dir: Path, | |
| tag: str, | |
| ) -> dict: | |
| cfg = { | |
| "hull_stl_path": str(hull_stl), | |
| "scale": float(scale), | |
| "release_pos": release_pos.tolist(), | |
| "release_quat_wxyz": release_quat.tolist(), | |
| "lin_vel_world": lin_vel_world.tolist(), | |
| "ang_vel_body": ang_vel_body.tolist(), | |
| "mass_kg": float(mass_kg), | |
| "friction": list(friction), | |
| "solref": list(solref), | |
| "solimp": list(solimp), | |
| "surface_pos": surface_pos.tolist(), | |
| "surface_quat_wxyz": surface_quat.tolist(), | |
| "surface_half_extent": surface_half_extent.tolist(), | |
| "floor_z": float(floor_z), | |
| "sim_dt": float(sim_dt), | |
| "max_sim_time": float(max_sim_time), | |
| "record_dt": float(record_dt), | |
| "settle_lin_vel_thresh": float(settle_lin_vel_thresh), | |
| "settle_ang_vel_thresh": float(settle_ang_vel_thresh), | |
| "settle_consecutive_steps": int(settle_consecutive_steps), | |
| } | |
| in_json = scratch_dir / f"worker_in_{tag}.json" | |
| out_json = scratch_dir / f"worker_out_{tag}.json" | |
| in_json.write_text(json.dumps(cfg)) | |
| cmd = [ | |
| str(mujoco_python), str(_WORKER_SCRIPT), | |
| "--in-json", str(in_json), "--out-json", str(out_json), | |
| ] | |
| logger.info("invoking mujoco env (%s): %s", tag, " ".join(cmd)) | |
| t0 = time.time() | |
| proc = subprocess.run(cmd, capture_output=True, text=True) | |
| logger.info( | |
| "mujoco subprocess (%s) finished in %.2fs, returncode=%d", | |
| tag, time.time() - t0, proc.returncode, | |
| ) | |
| if proc.stdout: | |
| logger.info("[mujoco stdout %s] %s", tag, proc.stdout.strip()) | |
| if proc.returncode != 0: | |
| logger.error("[mujoco stderr %s] %s", tag, proc.stderr.strip()) | |
| raise RuntimeError(f"mujoco settle worker ({tag}) failed with code {proc.returncode}") | |
| return json.loads(out_json.read_text()) | |
| def _resample_trajectory(worker_out: dict, t_query: np.ndarray) -> tuple[np.ndarray, np.ndarray]: | |
| """Resample a worker's (times, pos, quat) onto arbitrary query times. | |
| Linear interpolation for position, slerp for orientation; times beyond | |
| the last recorded sample hold the final (by then, settled -- velocity is | |
| below threshold by construction) pose rather than extrapolating. | |
| """ | |
| times = np.asarray(worker_out["times"], dtype=np.float64) | |
| pos = np.asarray(worker_out["pos"], dtype=np.float64) | |
| quat = np.asarray(worker_out["quat_wxyz"], dtype=np.float64) | |
| out_pos = np.empty((t_query.shape[0], 3), dtype=np.float64) | |
| out_quat = np.empty((t_query.shape[0], 4), dtype=np.float64) | |
| for i, tq in enumerate(t_query): | |
| if tq <= times[0]: | |
| out_pos[i] = pos[0] | |
| out_quat[i] = quat[0] | |
| continue | |
| if tq >= times[-1]: | |
| out_pos[i] = pos[-1] | |
| out_quat[i] = quat[-1] | |
| continue | |
| j = int(np.searchsorted(times, tq, side="right") - 1) | |
| j = min(max(j, 0), times.shape[0] - 2) | |
| frac = (tq - times[j]) / (times[j + 1] - times[j]) | |
| out_pos[i] = pos[j] + frac * (pos[j + 1] - pos[j]) | |
| out_quat[i] = _slerp(quat[j], quat[j + 1], float(frac)) | |
| return out_pos, out_quat | |
| # --------------------------------------------------------------------------- # | |
| # CLI | |
| # --------------------------------------------------------------------------- # | |
| def parse_args() -> argparse.Namespace: | |
| p = argparse.ArgumentParser( | |
| description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter | |
| ) | |
| p.add_argument( | |
| "--npz", type=Path, required=True, | |
| help="planner trajectory .npz (see plan_pick_place_books.py)", | |
| ) | |
| p.add_argument( | |
| "--sidecar-json", type=Path, default=None, | |
| help="plan sidecar JSON (default: <npz stem>.json)", | |
| ) | |
| p.add_argument( | |
| "--brick-outputs", type=Path, default=None, help="brick object-pipeline stage root" | |
| ) | |
| p.add_argument( | |
| "--books-outputs", type=Path, default=None, help="books placement-surface stage root" | |
| ) | |
| p.add_argument( | |
| "--placement-json", type=Path, default=None, help="override placement_target.json path" | |
| ) | |
| p.add_argument( | |
| "--release-frame-idx", type=int, default=None, help="override phases.release_frame_idx" | |
| ) | |
| p.add_argument( | |
| "--rest-frame-idx", type=int, default=0, help="frame index used as the as-scanned rest pose" | |
| ) | |
| p.add_argument( | |
| "--n-vel-frames", type=int, default=5, | |
| help="window size for release-velocity finite-difference", | |
| ) | |
| p.add_argument( | |
| "--mass-kg", type=float, default=0.03, | |
| help="ARBITRARY -- settle is mass-independent, see docstring", | |
| ) | |
| p.add_argument("--mass-kg-check", type=float, default=None, help="default: 10x --mass-kg") | |
| p.add_argument( | |
| "--friction", type=float, nargs=3, default=list(_ASSUMED_FRICTION), | |
| metavar=("SLIDE", "TORSION", "ROLL"), | |
| ) | |
| p.add_argument("--solref", type=float, nargs=2, default=list(_ASSUMED_SOLREF)) | |
| p.add_argument("--solimp", type=float, nargs=5, default=list(_ASSUMED_SOLIMP)) | |
| p.add_argument("--surface-thickness-m", type=float, default=0.02) | |
| p.add_argument( | |
| "--floor-drop-m", type=float, default=0.5, help="safety-net floor depth below the surface" | |
| ) | |
| p.add_argument("--sim-dt", type=float, default=0.0005) | |
| p.add_argument("--record-dt", type=float, default=0.005) | |
| p.add_argument("--max-sim-time", type=float, default=3.0) | |
| p.add_argument("--settle-lin-vel-thresh", type=float, default=0.01, help="m/s") | |
| p.add_argument("--settle-ang-vel-thresh", type=float, default=0.05, help="rad/s") | |
| p.add_argument( | |
| "--settle-consecutive-s", type=float, default=0.05, | |
| help="seconds below threshold to call it settled", | |
| ) | |
| p.add_argument( | |
| "--post-settle-buffer-s", type=float, default=0.5, | |
| help="extra held-still seconds appended for visibility", | |
| ) | |
| p.add_argument("--check-mass-independence", action="store_true", default=True) | |
| p.add_argument( | |
| "--no-check-mass-independence", dest="check_mass_independence", action="store_false" | |
| ) | |
| p.add_argument("--mujoco-python", type=Path, default=_DEFAULT_MUJOCO_PYTHON) | |
| p.add_argument("--scratch-dir", type=Path, default=None) | |
| p.add_argument("--out", type=Path, required=True) | |
| p.add_argument("--out-json", type=Path, required=True) | |
| return p.parse_args() | |
| def main() -> int: | |
| args = parse_args() | |
| setup_logging() | |
| plan = np.load(args.npz) | |
| timestamps = np.asarray(plan["timestamps"], dtype=np.float64) | |
| joint_positions = np.asarray(plan["joint_positions"], dtype=np.float64) | |
| gripper = np.asarray(plan["gripper"], dtype=np.float64) | |
| object_poses = np.asarray(plan["object_poses"], dtype=np.float64) | |
| object_names = plan["object_names"] | |
| n_frames_in = timestamps.shape[0] | |
| sidecar_path = args.sidecar_json or args.npz.with_suffix(".json") | |
| sidecar = json.loads(sidecar_path.read_text()) if sidecar_path.exists() else {} | |
| release_frame_idx = args.release_frame_idx | |
| if release_frame_idx is None: | |
| release_frame_idx = int(sidecar["phases"]["release_frame_idx"]) | |
| logger.info("release_frame_idx = %d (of %d input frames)", release_frame_idx, n_frames_in) | |
| brick_outputs = args.brick_outputs or Path(sidecar.get( | |
| "brick_outputs", | |
| REPO_ROOT / "outputs/AUTOLab+0d4edc83+2023-10-21-19h-07m-04s/objects_sam3d_multi/20_31", | |
| )) | |
| books_outputs = args.books_outputs or Path(sidecar.get( | |
| "books_outputs", | |
| REPO_ROOT / "outputs/AUTOLab+0d4edc83+2023-10-21-19h-07m-04s/objects_books/0_11", | |
| )) | |
| placement_json = args.placement_json or ( | |
| books_outputs / "objects" / "2_placement" / "placement_target.json" | |
| ) | |
| # --- geometry: mesh, alignment scale, convex hull ------------------------ | |
| mesh = rop._load_mesh(rop.stage_dir(brick_outputs, "mesh")) | |
| alignment = rop._load_alignment(rop.stage_dir(brick_outputs, "align")) | |
| verts_canonical = np.asarray(mesh.vertices, dtype=np.float64) | |
| faces = np.asarray(mesh.faces, dtype=np.int64) | |
| # Instructed method: uniform scale from the alignment transform's column | |
| # norms (should equal the same scale baked into every object_poses[t]). | |
| scale_cols = np.linalg.norm(np.asarray(alignment.transform, dtype=np.float64)[:3, :3], axis=0) | |
| scale = float(np.mean(scale_cols)) | |
| logger.info( | |
| "brick scale = %.6f m/unit (column norms %s, alignment.scale=%.6f), " | |
| "world extent = %s cm", | |
| scale, np.round(scale_cols, 6).tolist(), alignment.scale, | |
| np.round(mesh.extent * scale * 100.0, 2).tolist(), | |
| ) | |
| tri = trimesh.Trimesh(vertices=verts_canonical, faces=faces, process=False) | |
| hull = tri.convex_hull | |
| scratch_dir = args.scratch_dir or Path(tempfile.mkdtemp(prefix="settle_after_release_")) | |
| scratch_dir.mkdir(parents=True, exist_ok=True) | |
| hull_stl = scratch_dir / "brick_hull.stl" | |
| hull.export(str(hull_stl)) | |
| logger.info( | |
| "convex hull: %d verts / %d faces (raw mesh had %d/%d) -> %s", | |
| hull.vertices.shape[0], hull.faces.shape[0], | |
| verts_canonical.shape[0], faces.shape[0], hull_stl, | |
| ) | |
| # --- release pose, up-axis reference, release velocity ------------------- | |
| t_obj_rest = object_poses[args.rest_frame_idx, 0] | |
| e_up = _rest_up_axis_canonical(t_obj_rest) | |
| release_pose = object_poses[release_frame_idx, 0].copy() | |
| release_pos = release_pose[:3, 3].copy() | |
| r_release_pure = release_pose[:3, :3] / scale | |
| release_quat = _mat_to_quat_wxyz(r_release_pure) | |
| lin_vel_world, ang_vel_world = estimate_release_velocity( | |
| object_poses[:, 0], release_frame_idx, args.n_vel_frames, scale | |
| ) | |
| ang_vel_body = r_release_pure.T @ ang_vel_world | |
| logger.info( | |
| "release velocity: linear = %s m/s (|v|=%.4f), angular(world) = %s rad/s (|w|=%.4f)", | |
| np.round(lin_vel_world, 4).tolist(), np.linalg.norm(lin_vel_world), | |
| np.round(ang_vel_world, 4).tolist(), np.linalg.norm(ang_vel_world), | |
| ) | |
| # --- placement surface, book-top box, safety floor ----------------------- | |
| placement = json.loads(placement_json.read_text()) | |
| surf_pos = np.asarray(placement["position_m"], dtype=np.float64) | |
| surf_normal = np.asarray(placement["normal"], dtype=np.float64) | |
| surf_normal = surf_normal / np.linalg.norm(surf_normal) | |
| surf_extent = np.asarray(placement["surface_extent_m"], dtype=np.float64) | |
| u_hat, v_hat = _plane_basis(surf_normal) | |
| box_rot = np.stack([u_hat, v_hat, surf_normal], axis=1) # columns = local axes in world frame | |
| box_quat = _mat_to_quat_wxyz(box_rot) | |
| thickness = args.surface_thickness_m | |
| box_center = surf_pos - surf_normal * (thickness / 2.0) | |
| box_half_extent = np.array([surf_extent[0] / 2.0, surf_extent[1] / 2.0, thickness / 2.0]) | |
| floor_z = float(surf_pos[2] - args.floor_drop_m) | |
| tilt_at_release = tilt_from_flat_deg(r_release_pure, e_up, surf_normal) | |
| logger.info("tilt from flat AT RELEASE = %.2f deg (sidecar reports 37.26)", tilt_at_release) | |
| settle_consecutive_steps = max(1, round(args.settle_consecutive_s / args.sim_dt)) | |
| mass_check = args.mass_kg_check if args.mass_kg_check is not None else args.mass_kg * 10.0 | |
| common_kwargs = dict( | |
| mujoco_python=args.mujoco_python, hull_stl=hull_stl, scale=scale, | |
| release_pos=release_pos, release_quat=release_quat, | |
| lin_vel_world=lin_vel_world, ang_vel_body=ang_vel_body, | |
| friction=tuple(args.friction), solref=tuple(args.solref), solimp=tuple(args.solimp), | |
| surface_pos=box_center, surface_quat=box_quat, surface_half_extent=box_half_extent, | |
| floor_z=floor_z, sim_dt=args.sim_dt, max_sim_time=args.max_sim_time, | |
| record_dt=args.record_dt, | |
| settle_lin_vel_thresh=args.settle_lin_vel_thresh, | |
| settle_ang_vel_thresh=args.settle_ang_vel_thresh, | |
| settle_consecutive_steps=settle_consecutive_steps, scratch_dir=scratch_dir, | |
| ) | |
| worker_out = _run_worker(mass_kg=args.mass_kg, tag="primary", **common_kwargs) | |
| mass_check_result = None | |
| if args.check_mass_independence: | |
| worker_out_check = _run_worker(mass_kg=mass_check, tag="mass_check", **common_kwargs) | |
| p0 = np.asarray(worker_out["final_pos"]) | |
| p1 = np.asarray(worker_out_check["final_pos"]) | |
| q0 = np.asarray(worker_out["final_quat_wxyz"]) | |
| q1 = np.asarray(worker_out_check["final_quat_wxyz"]) | |
| r0 = _quat_wxyz_to_mat(q0) | |
| r1 = _quat_wxyz_to_mat(q1) | |
| rel = r1 @ r0.T | |
| rel = np.clip(rel, -1.0, 1.0) | |
| orient_diff_deg = float(np.degrees(np.linalg.norm(matrix_to_rotvec(rel)))) | |
| pos_diff_m = float(np.linalg.norm(p1 - p0)) | |
| mass_check_result = { | |
| "mass_kg_primary": args.mass_kg, | |
| "mass_kg_check": mass_check, | |
| "final_pos_primary": p0.tolist(), | |
| "final_pos_check": p1.tolist(), | |
| "position_diff_m": pos_diff_m, | |
| "orientation_diff_deg": orient_diff_deg, | |
| "settled_primary": worker_out["settled"], | |
| "settled_check": worker_out_check["settled"], | |
| } | |
| logger.info( | |
| "mass-independence check: %.3fkg vs %.3fkg -> " | |
| "position diff %.2e m, orientation diff %.4f deg", | |
| args.mass_kg, mass_check, pos_diff_m, orient_diff_deg, | |
| ) | |
| # --- resample the primary run onto the plan's 15 Hz clock ---------------- | |
| settle_time = worker_out["settle_time_s"] if worker_out["settled"] else args.max_sim_time | |
| span_s = settle_time + args.post_settle_buffer_s | |
| n_settle_frames = int(np.ceil(span_s * _TRAJ_FPS)) + 1 # +1: index0 == release frame itself | |
| n_frames_out = max(n_frames_in, release_frame_idx + n_settle_frames) | |
| new_timestamps = np.empty(n_frames_out, dtype=np.float64) | |
| new_timestamps[:release_frame_idx] = timestamps[:release_frame_idx] | |
| dt = 1.0 / _TRAJ_FPS | |
| for k in range(release_frame_idx, n_frames_out): | |
| new_timestamps[k] = timestamps[release_frame_idx] + (k - release_frame_idx) * dt | |
| new_joint_positions = np.empty((n_frames_out, joint_positions.shape[1]), dtype=np.float64) | |
| new_gripper = np.empty(n_frames_out, dtype=np.float64) | |
| n_copy = min(n_frames_in, n_frames_out) | |
| new_joint_positions[:n_copy] = joint_positions[:n_copy] | |
| new_gripper[:n_copy] = gripper[:n_copy] | |
| if n_frames_out > n_frames_in: | |
| # Settle outlasts the plan: hold the robot's final joint config (it | |
| # has nothing to do with the brick any more -- release already | |
| # happened) so the extension is visually a static, already-retracted arm. | |
| new_joint_positions[n_frames_in:] = joint_positions[-1] | |
| new_gripper[n_frames_in:] = gripper[-1] | |
| t_query = new_timestamps[release_frame_idx:] - new_timestamps[release_frame_idx] | |
| sim_pos, sim_quat = _resample_trajectory(worker_out, t_query) | |
| new_object_poses = np.empty((n_frames_out, 1, 4, 4), dtype=np.float64) | |
| new_object_poses[:release_frame_idx, 0] = object_poses[:release_frame_idx, 0] | |
| for i, k in enumerate(range(release_frame_idx, n_frames_out)): | |
| r_t = _quat_wxyz_to_mat(sim_quat[i]) | |
| mat = np.eye(4, dtype=np.float64) | |
| mat[:3, :3] = r_t * scale | |
| mat[:3, 3] = sim_pos[i] | |
| new_object_poses[k, 0] = mat | |
| # --- verification numbers -------------------------------------------- | |
| final_r_pure = _quat_wxyz_to_mat(np.asarray(worker_out["final_quat_wxyz"])) | |
| final_pos = np.asarray(worker_out["final_pos"], dtype=np.float64) | |
| final_tilt = tilt_from_flat_deg(final_r_pure, e_up, surf_normal) | |
| verts_world_final = scale * (verts_canonical @ final_r_pure.T) + final_pos | |
| height_above_plane = verts_world_final @ surf_normal - float(np.dot(surf_pos, surf_normal)) | |
| lowest_vertex_signed_dist = float(np.min(height_above_plane)) | |
| # brick body origin coincides with the release-frame world origin choice | |
| footprint_u = verts_world_final @ u_hat - float(np.dot(surf_pos, u_hat)) | |
| footprint_v = verts_world_final @ v_hat - float(np.dot(surf_pos, v_hat)) | |
| centroid_u = float(np.dot(final_pos, u_hat) - np.dot(surf_pos, u_hat)) | |
| centroid_v = float(np.dot(final_pos, v_hat) - np.dot(surf_pos, v_hat)) | |
| footprint_half_extent = np.array( | |
| [ | |
| max(abs(footprint_u.min()), abs(footprint_u.max())), | |
| max(abs(footprint_v.min()), abs(footprint_v.max())), | |
| ] | |
| ) | |
| # Every vertex's (u, v) must fall within the fitted surface's half-extent box. | |
| within_surface = bool( | |
| np.all(np.abs(footprint_u) <= surf_extent[0] / 2.0 + 1e-6) | |
| and np.all(np.abs(footprint_v) <= surf_extent[1] / 2.0 + 1e-6) | |
| ) | |
| dt_check = np.diff(new_timestamps) | |
| dt_ok = bool(np.allclose(dt_check, dt, atol=1e-9)) | |
| r = release_frame_idx | |
| pre_release_identical = bool( | |
| np.array_equal(new_timestamps[:r], timestamps[:r]) | |
| and np.array_equal(new_object_poses[:r], object_poses[:r]) | |
| and np.array_equal(new_joint_positions[:r], joint_positions[:r]) | |
| and np.array_equal(new_gripper[:r], gripper[:r]) | |
| ) | |
| logger.info("=== verification ===") | |
| logger.info( | |
| "final tilt from flat = %.2f deg (release was %.2f deg)", final_tilt, tilt_at_release | |
| ) | |
| logger.info( | |
| "lowest vertex signed distance to surface plane = %.4f m (>=0 means no penetration)", | |
| lowest_vertex_signed_dist, | |
| ) | |
| logger.info( | |
| "centroid in-plane offset from surface centre = (%.4f, %.4f) m; " | |
| "footprint half-extent used (u, v) = %s m vs surface half-extent %s m -> within_surface=%s", | |
| centroid_u, centroid_v, np.round(footprint_half_extent, 4).tolist(), | |
| np.round(surf_extent / 2.0, 4).tolist(), within_surface, | |
| ) | |
| logger.info("dt exactly 1/15 across extended trajectory: %s", dt_ok) | |
| logger.info("pre-release portion bit-identical to input: %s", pre_release_identical) | |
| if not within_surface: | |
| logger.warning( | |
| "brick's settled footprint extends OUTSIDE the fitted book surface -- " | |
| "it slid/rolled off." | |
| ) | |
| np.savez( | |
| args.out, | |
| timestamps=new_timestamps, | |
| joint_positions=new_joint_positions, | |
| gripper=new_gripper, | |
| object_poses=new_object_poses, | |
| object_names=object_names, | |
| ) | |
| logger.info("wrote %s (%d frames, was %d)", args.out, n_frames_out, n_frames_in) | |
| sidecar_out = { | |
| "source_npz": str(args.npz), | |
| "source_sidecar_json": str(sidecar_path) if sidecar_path.exists() else None, | |
| "release_frame_idx": release_frame_idx, | |
| "n_frames_in": n_frames_in, | |
| "n_frames_out": n_frames_out, | |
| "release_pose": { | |
| "position_m": release_pos.tolist(), | |
| "rotation_matrix_pure": r_release_pure.tolist(), | |
| "quat_wxyz": release_quat.tolist(), | |
| "tilt_from_flat_deg": tilt_at_release, | |
| }, | |
| "inherited_velocity": { | |
| "linear_world_mps": lin_vel_world.tolist(), | |
| "linear_speed_mps": float(np.linalg.norm(lin_vel_world)), | |
| "angular_world_radps": ang_vel_world.tolist(), | |
| "angular_speed_radps": float(np.linalg.norm(ang_vel_world)), | |
| "n_frames_used_for_finite_difference": args.n_vel_frames, | |
| "note": "finite-differenced from the recorded (rigidly-attached) brick poses " | |
| "in the few frames immediately before release -- the brick does not " | |
| "drop from rest, it carries the hand's motion at the instant of release.", | |
| }, | |
| "physics_assumptions": { | |
| "mass_kg": args.mass_kg, | |
| "mass_is_load_bearing": False, | |
| "mass_note": "ARBITRARY. A rigid body's fall-and-settle trajectory under gravity " | |
| "+ contact + friction is mass-independent (mass cancels out of F=ma when every " | |
| "force in the scene, including contact/friction, itself scales with mass) -- " | |
| "verified empirically below by re-running at 10x mass.", | |
| "friction_sliding_torsional_rolling": list(args.friction), | |
| "friction_is_load_bearing": True, | |
| "friction_note": "ASSUMPTION, never measured for this brick/book contact.", | |
| "solref": list(args.solref), | |
| "solimp": list(args.solimp), | |
| "restitution_is_load_bearing": True, | |
| "restitution_note": "ASSUMPTION. MuJoCo has no literal coefficient-of-restitution " | |
| "attribute; bounciness is entirely a function of how underdamped `solref` is. " | |
| "The values used (MuJoCo's own defaults) are critically damped, i.e. essentially " | |
| "no bounce -- reasonable for a small, light brick, but not measured.", | |
| }, | |
| "mass_independence_check": mass_check_result, | |
| "simulation": { | |
| "sim_dt_s": args.sim_dt, | |
| "n_sim_steps": worker_out["n_sim_steps"], | |
| "settled": worker_out["settled"], | |
| "settle_time_s": worker_out["settle_time_s"], | |
| "sim_wall_time_s": worker_out["sim_wall_time_s"], | |
| "mujoco_version": worker_out["mujoco_version"], | |
| "hull_stl": str(hull_stl), | |
| "hull_n_vertices": int(hull.vertices.shape[0]), | |
| "hull_n_faces": int(hull.faces.shape[0]), | |
| }, | |
| "final_pose": { | |
| "position_m": final_pos.tolist(), | |
| "rotation_matrix_pure": final_r_pure.tolist(), | |
| "tilt_from_flat_deg": final_tilt, | |
| "tilt_from_flat_deg_at_release": tilt_at_release, | |
| }, | |
| "verification": { | |
| "final_tilt_deg_lower_than_release": final_tilt < tilt_at_release, | |
| "lowest_vertex_signed_distance_to_surface_m": lowest_vertex_signed_dist, | |
| "no_penetration": lowest_vertex_signed_dist >= -1e-4, | |
| "centroid_in_plane_offset_uv_m": [centroid_u, centroid_v], | |
| "footprint_half_extent_uv_m": footprint_half_extent.tolist(), | |
| "surface_half_extent_uv_m": (surf_extent / 2.0).tolist(), | |
| "within_surface_footprint": within_surface, | |
| "dt_exactly_1_over_15": dt_ok, | |
| "pre_release_portion_bit_identical": pre_release_identical, | |
| }, | |
| } | |
| args.out_json.write_text(json.dumps(sidecar_out, indent=2, default=str)) | |
| logger.info("wrote %s", args.out_json) | |
| print(json.dumps(sidecar_out["verification"], indent=2)) | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
Xet Storage Details
- Size:
- 36.2 kB
- Xet hash:
- a3f577f773510833848db343a1d66db293e168566df0527aec8b844e27e04af9
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.