Buckets:
| #!/usr/bin/env python | |
| """Export per-frame geometry-conditioning channels for a video diffusion model. | |
| Given a planner-produced kinematic trajectory (robot joints + gripper + | |
| per-object world poses) for a pick-and-place, this renders the | |
| ControlNet-depth / Cosmos-Transfer-style channels a conditioned video model | |
| consumes: RGB, 16-bit depth, an instance segmentation, and per-frame pose | |
| metadata, plus the camera and a human-viewable preview clip. | |
| Input contract (produced by a planner elsewhere in the pipeline, fixed): | |
| a ``.npz`` with ``timestamps`` (T,), ``joint_positions`` (T, 7), ``gripper`` | |
| (T,) in DROID's 0=open convention, ``object_poses`` (T, n_obj, 4, 4) world | |
| poses, ``object_names`` (n_obj,). Every field but ``timestamps`` is treated | |
| as optional here: a missing/malformed field disables exactly the channel(s) | |
| it feeds (no robot rendered without joint_positions, no objects without | |
| object_poses) with a loud warning, rather than a KeyError mid-loop -- the | |
| planner is produced by a different, concurrently-running process, so this | |
| script cannot assume its output is perfect on the first try. | |
| The static background (table, shelf, whatever the camera actually saw) comes | |
| from one real depth capture of a DROID episode, unprojected into a coloured | |
| point cloud and then *carved* of the robot/object silhouettes the camera saw | |
| at that instant -- exactly render_pointcloud_scene.py's approach, reused | |
| directly rather than reimplemented (see the imports below). Skipping the | |
| carve would render the arm and object twice: once frozen in the capture | |
| photo, once moving under the planner's trajectory -- the same surface | |
| represented twice, precisely the wrong signal for geometry conditioning. | |
| Per-frame instance masks (background / robot / object 1 / object 2 / ...) use | |
| the same two-pass depth-agreement trick as | |
| :class:`fpgm.objects.scene.SceneRenderer` (src/fpgm/objects/scene.py:184-230), | |
| generalised from one object to N: render everything together for the | |
| occlusion-correct image, then render each entity alone (everything else | |
| detached) and credit it with whichever pixels its own unoccluded depth | |
| agrees with the combined depth. This needs no alpha/object-id channel from | |
| OSMesa (which this build does not reliably expose either). | |
| Usage: | |
| PYTHONPATH=src python scripts/render_conditioning_set.py \\ | |
| --npz outputs/planner_trajectory.npz \\ | |
| --episode AUTOLab+0d4edc83+2023-10-21-19h-07m-04s --camera ext1 --clip 15:26 \\ | |
| --object-asset brick=outputs/.../objects_sam3d_multi/15_26 \\ | |
| --out-dir outputs/conditioning_set --mode fixed | |
| """ | |
| from __future__ import annotations | |
| import argparse | |
| import importlib.util | |
| import json | |
| import resource | |
| import sys | |
| import time | |
| from dataclasses import dataclass, field | |
| from pathlib import Path | |
| import h5py | |
| import numpy as np | |
| import trimesh | |
| from PIL import Image | |
| REPO_ROOT = Path(__file__).resolve().parents[1] | |
| sys.path.insert(0, str(REPO_ROOT / "src")) | |
| # NOTE on ordering (also present, and also flagged by ruff's isort check, in | |
| # render_pointcloud_scene.py -- this repo's existing convention): `pyrender` | |
| # is imported last, after `fpgm.robot.render`, whose import sets | |
| # PYOPENGL_PLATFORM=osmesa and stubs pyrender.viewer as a side effect. Both | |
| # must happen before pyrender/OpenGL is first imported anywhere in the | |
| # process (see that module's docstring) -- ruff's import sorter does not know | |
| # about this constraint and will suggest hoisting `pyrender` above the | |
| # alphabetised block; do not apply that fix, it silently breaks headless GL. | |
| from fpgm.data.droid_raw import cam2world_vector_to_world2cam # noqa: E402 | |
| from fpgm.geometry.camera import Camera # noqa: E402 | |
| from fpgm.geometry.transforms import invert_se3 # noqa: E402 | |
| from fpgm.objects.scene import free_camera # noqa: E402 | |
| from fpgm.robot.render import RobotRenderer # noqa: E402 | |
| from fpgm.robot.urdf import RobotModel # noqa: E402 | |
| from fpgm.types import DataError # noqa: E402 | |
| from fpgm.utils.io import ensure_dir # noqa: E402 | |
| from fpgm.utils.logging import get_logger, setup_logging # noqa: E402 | |
| from fpgm.viz.overlays import VideoWriter # noqa: E402 | |
| import pyrender # noqa: E402 | |
| # Reuse render_pointcloud_scene.py's point-cloud unprojection/carving/drawing | |
| # machinery wholesale rather than reimplementing it -- same importlib trick | |
| # that module itself uses for run_object_pipeline.py (sys.modules[...] MUST be | |
| # set before exec_module, or that module's own @dataclass decorators break). | |
| _rps_spec = importlib.util.spec_from_file_location( | |
| "render_pointcloud_scene", REPO_ROOT / "scripts" / "render_pointcloud_scene.py" | |
| ) | |
| rps = importlib.util.module_from_spec(_rps_spec) | |
| sys.modules[_rps_spec.name] = rps | |
| _rps_spec.loader.exec_module(rps) | |
| rop = rps.rop # run_object_pipeline, already imported the same way by rps itself | |
| logger = get_logger("render_conditioning_set") | |
| _DEFAULT_URDF = rop._DEFAULT_URDF | |
| #: OSMesa (software GL) leaks ~180MB/frame on this host regardless of scene | |
| #: reuse (measured in scripts/render_robot_random.py); recreating the renderer | |
| #: every N frames bounds that growth instead of OOMing the shared host over a | |
| #: long export. Same mitigation, same default interval. | |
| _RENDERER_RECYCLE = 10 | |
| #: Generic small-object stand-in used only when no real mesh asset is given | |
| #: for an object name -- keeps the export running (never a crash) but must | |
| #: never be mistaken for the real reconstructed geometry. | |
| _PLACEHOLDER_EXTENT_M = 0.08 | |
| _PLACEHOLDER_PALETTE_RGB: tuple[tuple[int, int, int], ...] = ( | |
| (220, 120, 60), (60, 160, 220), (160, 220, 60), (220, 60, 160), (60, 220, 160), | |
| ) | |
| #: Franka/Robotiq links whose FK positions define the TCP, matching the | |
| #: convention already established by fpgm.robot.kinematics.ArmKinematics (see | |
| #: its _build_flange_table docstring): fingertip midpoint for position, | |
| #: gripper-base rotation for orientation. Not reused via that class directly | |
| #: since it additionally demands a validated all-Z-axis arm for its fast | |
| #: Jacobian path -- more machinery than one FK lookup per frame needs here. | |
| _TCP_FINGER_LINKS = ("left_inner_finger", "right_inner_finger") | |
| _TCP_ROTATION_LINK = "robotiq_85_base_link" | |
| _BASIS_POINTS = np.array( | |
| [[0.0, 0.0, 0.0], [1.0, 0.0, 0.0], [0.0, 1.0, 0.0], [0.0, 0.0, 1.0]] | |
| ) | |
| 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 docstring") | |
| p.add_argument("--action-json", type=Path, default=None, help="sidecar action-spec JSON") | |
| p.add_argument("--episode", required=True, help="DROID episode the background capture is from") | |
| p.add_argument("--camera", default="ext1", choices=["ext1", "ext2"]) | |
| p.add_argument( | |
| "--clip", required=True, | |
| help='PointWorld clip key whose initial_depth/rgb is the background, e.g. "15:26"', | |
| ) | |
| p.add_argument( | |
| "--object-asset", action="append", default=None, metavar="NAME=DIR", | |
| help="stage_root directory (containing objects/2_mesh and objects/3_align, i.e. an " | |
| "outputs/<episode>/objects* tree) providing the real mesh + capture-time alignment " | |
| "for object NAME. Repeatable. An object present in --npz with no matching entry " | |
| "here renders as a placeholder box and is not carved out of the background cloud.", | |
| ) | |
| p.add_argument("--urdf", type=Path, default=_DEFAULT_URDF) | |
| p.add_argument("--out-dir", type=Path, required=True) | |
| p.add_argument( | |
| "--mode", choices=["fixed", "orbit"], default="fixed", | |
| help="fixed = the real capture viewpoint; orbit = swing the camera to reveal 3D structure", | |
| ) | |
| p.add_argument("--orbit-degrees", type=float, default=50.0) | |
| p.add_argument("--width", type=int, default=640) | |
| p.add_argument("--height", type=int, default=360) | |
| p.add_argument("--max-depth", type=float, default=3.0, help="drop far cloud points, metres") | |
| p.add_argument("--point-radius", type=int, default=2) | |
| p.add_argument( | |
| "--robot-dilate-px", type=int, default=2, | |
| help="dilate the robot silhouette before carving, to catch depth bleed at edges", | |
| ) | |
| p.add_argument("--object-pad-m", type=float, default=0.02) | |
| p.add_argument( | |
| "--renderer-recycle", type=int, default=_RENDERER_RECYCLE, | |
| help="recreate the OSMesa renderer every N frames (0 disables; see module docstring)", | |
| ) | |
| p.add_argument("--max-frames", type=int, default=None, help="render at most N frames (debug)") | |
| return p.parse_args() | |
| # --------------------------------------------------------------------------- # | |
| # Planner .npz: load field-by-field, never crash mid-loop on a missing one. | |
| # --------------------------------------------------------------------------- # | |
| class PlannerTrajectory: | |
| """The subset of the input contract that actually loaded, plus what didn't. | |
| Every array field is ``None`` if the source ``.npz`` did not have it (or | |
| had it in the wrong shape) -- callers switch off exactly the channel that | |
| field feeds rather than assuming it is present. | |
| """ | |
| timestamps: np.ndarray # (T,) float64, seconds | |
| joint_positions: np.ndarray | None # (T, 7) or None | |
| gripper: np.ndarray # (T,), defaults to all-open (0.0) if absent | |
| object_poses: np.ndarray | None # (T, n_obj, 4, 4) or None | |
| object_names: list[str] # len 0 if object_poses is None | |
| fps: float | |
| warnings: list[str] = field(default_factory=list) | |
| def n_frames(self) -> int: | |
| return int(self.timestamps.shape[0]) | |
| _EXPECTED_FPS = 15.0 | |
| def _load_planner_npz(path: Path) -> PlannerTrajectory: | |
| """Load the planner's ``.npz`` against the fixed input contract. | |
| ``timestamps`` is the only field treated as truly required (without it | |
| there is no way to know how many frames to render at all); everything | |
| else degrades to "channel disabled" plus a logged warning, per the module | |
| docstring. | |
| """ | |
| if not path.exists(): | |
| raise DataError(f"planner npz not found: {path}") | |
| warns: list[str] = [] | |
| with np.load(path, allow_pickle=False) as npz: | |
| keys = set(npz.files) | |
| if "timestamps" not in keys: | |
| raise DataError(f"{path}: missing required 'timestamps' field; frame count unknown") | |
| timestamps = np.asarray(npz["timestamps"], dtype=np.float64).reshape(-1) | |
| t = timestamps.shape[0] | |
| if t == 0: | |
| raise DataError(f"{path}: 'timestamps' is empty") | |
| dt = np.diff(timestamps) | |
| fps = _EXPECTED_FPS | |
| if dt.size: | |
| if not np.allclose(dt, dt[0], atol=1e-6): | |
| warns.append( | |
| f"timestamps not uniformly spaced (dt {dt.min():.4f}-{dt.max():.4f}s)" | |
| ) | |
| if dt[0] > 0: | |
| fps = 1.0 / float(dt[0]) | |
| if abs(fps - _EXPECTED_FPS) > 0.5: | |
| warns.append( | |
| f"fps inferred from timestamps ({fps:.2f}) is far from expected {_EXPECTED_FPS}" | |
| ) | |
| joint_positions = None | |
| if "joint_positions" not in keys: | |
| warns.append("no 'joint_positions' field: the robot will NOT be rendered") | |
| else: | |
| jp = np.asarray(npz["joint_positions"], dtype=np.float64) | |
| if jp.shape != (t, 7): | |
| warns.append( | |
| f"'joint_positions' has shape {jp.shape}, expected {(t, 7)}: " | |
| "the robot will NOT be rendered" | |
| ) | |
| else: | |
| joint_positions = jp | |
| gripper = np.zeros(t, dtype=np.float64) | |
| if "gripper" not in keys: | |
| warns.append("no 'gripper' field: defaulting every frame to open (0.0)") | |
| else: | |
| g = np.asarray(npz["gripper"], dtype=np.float64).reshape(-1) | |
| if g.shape[0] != t: | |
| warns.append(f"'gripper' has length {g.shape[0]}, expected {t}: defaulting to open") | |
| else: | |
| gripper = g | |
| object_names: list[str] = [] | |
| if "object_names" in keys: | |
| object_names = [str(n) for n in np.asarray(npz["object_names"]).reshape(-1)] | |
| object_poses = None | |
| if "object_poses" not in keys: | |
| warns.append("no 'object_poses' field: no objects will be rendered") | |
| object_names = [] | |
| else: | |
| op = np.asarray(npz["object_poses"], dtype=np.float64) | |
| if op.ndim != 4 or op.shape[0] != t or op.shape[2:] != (4, 4): | |
| warns.append( | |
| f"'object_poses' has shape {op.shape}, expected {(t, '<n_obj>', 4, 4)}: " | |
| "no objects will be rendered" | |
| ) | |
| object_names = [] | |
| elif op.shape[1] != len(object_names): | |
| warns.append( | |
| f"'object_poses' carries {op.shape[1]} objects but 'object_names' has " | |
| f"{len(object_names)} entries: no objects will be rendered" | |
| ) | |
| object_names = [] | |
| else: | |
| object_poses = op | |
| for w in warns: | |
| logger.warning("%s: %s", path, w) | |
| return PlannerTrajectory( | |
| timestamps=timestamps, joint_positions=joint_positions, gripper=gripper, | |
| object_poses=object_poses, object_names=object_names, fps=fps, warnings=warns, | |
| ) | |
| # --------------------------------------------------------------------------- # | |
| # Object assets: real mesh + capture-time pose (for carving), or a graceful | |
| # placeholder when no real asset is available for a name. | |
| # --------------------------------------------------------------------------- # | |
| class ObjectAsset: | |
| name: str | |
| mesh: trimesh.Trimesh | |
| #: mesh-canonical -> world transform at the instant the background depth | |
| #: was captured -- needed only to carve this object's real-capture ghost | |
| #: out of the point cloud. ``None`` when unknown (placeholder, or no | |
| #: --object-asset given), in which case carving that object is skipped. | |
| capture_transform: np.ndarray | None | |
| is_real: bool | |
| def _parse_object_assets(pairs: list[str] | None) -> dict[str, Path]: | |
| result: dict[str, Path] = {} | |
| for pair in pairs or []: | |
| if "=" not in pair: | |
| raise SystemExit(f"--object-asset must be NAME=DIR, got {pair!r}") | |
| name, _, dir_str = pair.partition("=") | |
| result[name] = Path(dir_str) | |
| return result | |
| def _placeholder_object_mesh(color_rgb: tuple[int, int, int]) -> trimesh.Trimesh: | |
| tri = trimesh.creation.box(extents=[_PLACEHOLDER_EXTENT_M] * 3) | |
| fill = np.array([*color_rgb, 255], dtype=np.uint8) | |
| tri.visual.vertex_colors = np.tile(fill, (len(tri.vertices), 1)) | |
| return tri | |
| def _load_object_assets(names: list[str], asset_dirs: dict[str, Path]) -> list[ObjectAsset]: | |
| assets: list[ObjectAsset] = [] | |
| for i, name in enumerate(names): | |
| color = _PLACEHOLDER_PALETTE_RGB[i % len(_PLACEHOLDER_PALETTE_RGB)] | |
| stage_root = asset_dirs.get(name) | |
| if stage_root is None: | |
| logger.warning( | |
| "no --object-asset given for %r; substituting a %.0fcm placeholder box " | |
| "(NOT the real geometry) and skipping cloud carving for it", | |
| name, _PLACEHOLDER_EXTENT_M * 100, | |
| ) | |
| assets.append(ObjectAsset(name, _placeholder_object_mesh(color), None, False)) | |
| continue | |
| try: | |
| tri, transform = rps._load_object(stage_root) | |
| except Exception as exc: # noqa: BLE001 - degrade to a placeholder, never crash the export | |
| logger.warning( | |
| "failed to load object asset %r from %s (%s); substituting a placeholder box", | |
| name, stage_root, exc, | |
| ) | |
| assets.append(ObjectAsset(name, _placeholder_object_mesh(color), None, False)) | |
| continue | |
| assets.append(ObjectAsset(name, tri, transform, True)) | |
| return assets | |
| # --------------------------------------------------------------------------- # | |
| # Rendering: robot + N objects, one pyrender scene, per-entity instance masks. | |
| # --------------------------------------------------------------------------- # | |
| class _ConditioningRenderer: | |
| """Robot + N independently-posed objects, with a disjoint per-entity mask. | |
| Generalises :class:`fpgm.objects.scene.SceneRenderer`'s two-pass | |
| depth-agreement trick (src/fpgm/objects/scene.py:184-230) from exactly one | |
| object to however many the trajectory carries: render everything together | |
| for the occlusion-correct composite, then for each object render it alone | |
| (every other node -- every robot link, every other object -- detached) and | |
| credit it with whichever combined-mask pixels its own unoccluded depth | |
| agrees with (within :attr:`_MASK_DEPTH_ATOL`). Every combined-mask pixel no | |
| object claims is the robot's, by exclusion -- exact at silhouette | |
| boundaries, and needs no object-id/alpha channel from OSMesa (which this | |
| build does not reliably expose either, see RobotRenderer's own note). | |
| Costs one extra render pass per object per frame -- cheap next to scene | |
| construction, which is what actually dominates OSMesa's software runtime. | |
| """ | |
| _MASK_DEPTH_ATOL = 1e-4 | |
| def __init__( | |
| self, | |
| link_meshes: dict[str, list[tuple[trimesh.Trimesh, np.ndarray]]], | |
| object_meshes: list[trimesh.Trimesh], | |
| width: int, | |
| height: int, | |
| **robot_kwargs: object, | |
| ) -> None: | |
| self._robot = RobotRenderer(link_meshes, width, height, **robot_kwargs) | |
| self._object_nodes: list[pyrender.Node] = [] | |
| for tri in object_meshes: | |
| pmesh = pyrender.Mesh.from_trimesh(tri, smooth=False) | |
| node = pyrender.Node(mesh=pmesh, matrix=np.eye(4)) | |
| self._robot.scene.add_node(node) | |
| self._object_nodes.append(node) | |
| # Flat list of every robot link's node, toggled off/on per object-alone | |
| # pass below -- reaches into RobotRenderer's own bookkeeping rather | |
| # than duplicating it, same integration point SceneRenderer uses. | |
| self._link_nodes = [ | |
| node for entries in self._robot._link_nodes.values() for node, _ in entries | |
| ] | |
| def render( | |
| self, | |
| link_poses: dict[str, np.ndarray], | |
| object_poses: list[np.ndarray], | |
| camera: Camera, | |
| ): | |
| """Returns ``(combined_result, robot_mask, object_masks)``. | |
| ``object_masks[i]`` corresponds to the i-th mesh this renderer was | |
| constructed with, in order. | |
| """ | |
| for node, pose in zip(self._object_nodes, object_poses, strict=True): | |
| self._robot.scene.set_pose(node, np.asarray(pose, dtype=np.float64)) | |
| combined = self._robot.render(link_poses, camera) | |
| object_masks: list[np.ndarray] = [] | |
| for i, _node in enumerate(self._object_nodes): | |
| others = self._link_nodes + [n for j, n in enumerate(self._object_nodes) if j != i] | |
| for n in others: | |
| self._robot.scene.remove_node(n) | |
| try: | |
| _, solo_depth = self._robot._renderer.render(self._robot.scene) | |
| finally: | |
| for n in others: | |
| self._robot.scene.add_node(n) | |
| solo_depth = np.asarray(solo_depth, dtype=np.float32) | |
| object_masks.append( | |
| combined.mask | |
| & (solo_depth > 0.0) | |
| & (np.abs(solo_depth - combined.depth) <= self._MASK_DEPTH_ATOL) | |
| ) | |
| any_object = np.zeros(combined.mask.shape, dtype=bool) | |
| for m in object_masks: | |
| any_object |= m | |
| robot_mask = combined.mask & ~any_object | |
| return combined, robot_mask, object_masks | |
| def close(self) -> None: | |
| self._robot.close() | |
| def _composite_depth( | |
| mesh_depth: np.ndarray, uv: np.ndarray, point_depth: np.ndarray, radius: int | |
| ) -> np.ndarray: | |
| """Depth channel, metres; 0 = no data. | |
| Mirrors the compositing rule the main loop's rgb canvas actually ends up | |
| with, not a naive nearer-of-the-two z-test: after | |
| ``render_pointcloud_scene._draw_points`` stamps cloud points (respecting | |
| its own visibility test), the caller unconditionally overwrites every | |
| mesh-covered pixel with the mesh colour (``canvas[combined.mask] = ...``) | |
| -- so the mesh silhouette always wins on-screen regardless of whether a | |
| background point happens to be nearer there. A two-way z-test here would | |
| let this channel disagree with what rgb/seg actually show at exactly | |
| those pixels (checked against real data: it does happen, wherever the | |
| background pokes into the mesh's 2D silhouette without truly being in | |
| front of it in the mesh's own occluded corner). So: mesh depth is | |
| authoritative on its own footprint, and only pixels with no mesh at all | |
| (``mesh_depth <= 0``, matching ``seg == 0``) are filled from the carved | |
| cloud -- nearest cloud point among those that land there, same stamp | |
| radius as ``_draw_points``. | |
| """ | |
| h, w = mesh_depth.shape | |
| out = mesh_depth.copy() | |
| empty = out <= 0.0 | |
| u = np.round(uv[:, 0]).astype(np.int64) | |
| v = np.round(uv[:, 1]).astype(np.int64) | |
| keep = (point_depth > 0) & (u >= 0) & (u < w) & (v >= 0) & (v < h) | |
| u, v, point_depth = u[keep], v[keep], point_depth[keep] | |
| order = np.argsort(point_depth)[::-1] # far first, so nearer points overwrite | |
| u, v, point_depth = u[order], v[order], point_depth[order] | |
| r = int(radius) | |
| for du in range(-r, r + 1): | |
| for dv in range(-r, r + 1): | |
| if du * du + dv * dv > r * r: | |
| continue | |
| uu = np.clip(u + du, 0, w - 1) | |
| vv = np.clip(v + dv, 0, h - 1) | |
| can_write = empty[vv, uu] | |
| out[vv[can_write], uu[can_write]] = point_depth[can_write] | |
| return out | |
| def _tcp_pose(link_poses: dict[str, np.ndarray]) -> np.ndarray | None: | |
| """base_link -> TCP: fingertip-midpoint position, gripper-base orientation. | |
| Matches the convention :class:`fpgm.robot.kinematics.ArmKinematics` is | |
| built against (see its ``_build_flange_table`` docstring) without pulling | |
| in that class, which additionally validates an all-Z-axis arm -- more than | |
| one FK dict lookup per frame needs. | |
| """ | |
| try: | |
| left = link_poses[_TCP_FINGER_LINKS[0]][:3, 3] | |
| right = link_poses[_TCP_FINGER_LINKS[1]][:3, 3] | |
| rot = link_poses[_TCP_ROTATION_LINK][:3, :3] | |
| except KeyError as exc: | |
| logger.warning("URDF has no link %s; cannot compute tcp_pose", exc) | |
| return None | |
| pose = np.eye(4, dtype=np.float64) | |
| pose[:3, :3] = rot | |
| pose[:3, 3] = (left + right) / 2.0 | |
| return pose | |
| def _camera_to_world(camera: Camera) -> np.ndarray: | |
| """Recover `camera`'s (4, 4) cam->world matrix via its public point-transform API. | |
| :class:`~fpgm.geometry.camera.Camera` deliberately exposes only | |
| point-transform methods, not a raw extrinsic matrix -- this mirrors the | |
| exact same reconstruction ``fpgm.robot.render._camera_to_world_matrix`` | |
| already uses for that reason, rather than reaching into a private | |
| attribute. | |
| """ | |
| transformed = camera.cam_to_world(_BASIS_POINTS) | |
| origin = transformed[0] | |
| rotation = (transformed[1:] - origin).T | |
| m = np.eye(4, dtype=np.float64) | |
| m[:3, :3] = rotation | |
| m[:3, 3] = origin | |
| return m | |
| def _world_to_cam_matrix(camera: Camera) -> np.ndarray: | |
| return invert_se3(_camera_to_world(camera)) | |
| def _rss_mb() -> int: | |
| """Process peak RSS so far, MB. Linux ``ru_maxrss`` is kilobytes and monotonic | |
| non-decreasing across the process's lifetime -- exactly what is needed to | |
| confirm the OSMesa-leak mitigation is bounding growth over a long export.""" | |
| return int(resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024) | |
| # --------------------------------------------------------------------------- # | |
| # Main | |
| # --------------------------------------------------------------------------- # | |
| def main() -> int: | |
| args = parse_args() | |
| setup_logging() | |
| traj = _load_planner_npz(args.npz) | |
| n_frames = traj.n_frames | |
| if args.max_frames is not None: | |
| n_frames = min(n_frames, args.max_frames) | |
| logger.info("capping render to %d/%d frames (--max-frames)", n_frames, traj.n_frames) | |
| out_dir = args.out_dir | |
| rgb_dir = ensure_dir(out_dir / "rgb") | |
| depth_dir = ensure_dir(out_dir / "depth") | |
| seg_dir = ensure_dir(out_dir / "seg") | |
| pose_dir = ensure_dir(out_dir / "pose") | |
| # --- Background: one real depth capture, carved of whatever robot/object | |
| # the camera actually saw frozen in that frame (mandatory, see module docstring). | |
| ctx = rop._load_clip_context(args.episode, args.camera, args.clip) | |
| metadata = rop._load_metadata(args.episode) | |
| serial = metadata[f"{args.camera}_cam_serial"] | |
| depth_u16 = rop._load_initial_depth(args.episode, args.clip, serial) | |
| cloud_xyz, cloud_rgb = rps._scene_point_cloud(ctx, depth_u16, args.max_depth) | |
| logger.info("background cloud: %d points before carving", cloud_xyz.shape[0]) | |
| robot = RobotModel(str(args.urdf), load_meshes=True) | |
| # The trajectory row / camera pose the depth frame was actually captured | |
| # at -- from the REAL episode, independent of the planner's (possibly | |
| # synthetic) trajectory, since carving must remove what the camera really | |
| # saw, not what the plan says happened. | |
| capture_row = int(round(ctx.timing.clip_frame_to_trajectory_frame(0))) | |
| with h5py.File(rop._load_trajectory_h5(args.episode), "r") as f: | |
| real_joint_positions = np.asarray(f["observation/robot_state/joint_positions"]) | |
| real_gripper = np.asarray(f["observation/robot_state/gripper_position"]) | |
| extrinsics_6d = np.asarray(f[f"observation/camera_extrinsics/{serial}_left"]) | |
| capture_row = int( | |
| np.clip(capture_row, 0, min(real_joint_positions.shape[0], extrinsics_6d.shape[0]) - 1) | |
| ) | |
| capture_w2c = cam2world_vector_to_world2cam(extrinsics_6d[capture_row]) | |
| cloud_xyz, cloud_rgb = rps._drop_robot_points( | |
| cloud_xyz, cloud_rgb, robot, | |
| real_joint_positions[capture_row], float(real_gripper[capture_row]), | |
| ctx, capture_w2c, args.robot_dilate_px, | |
| ) | |
| asset_dirs = _parse_object_assets(args.object_asset) | |
| object_names = traj.object_names | |
| object_assets = _load_object_assets(object_names, asset_dirs) | |
| for asset in object_assets: | |
| if asset.capture_transform is not None: | |
| cloud_xyz, cloud_rgb = rps._drop_object_points( | |
| cloud_xyz, cloud_rgb, asset.mesh, asset.capture_transform, | |
| args.object_pad_m, asset.name, | |
| ) | |
| else: | |
| logger.warning( | |
| "object %r has no known capture-time pose (no real --object-asset alignment); " | |
| "it cannot be carved out of the background cloud, so a ghost of it may remain", | |
| asset.name, | |
| ) | |
| logger.info("background cloud: %d points after carving", cloud_xyz.shape[0]) | |
| K = ctx.camera_annot.K.scaled(args.width, args.height) | |
| base_cam = Camera(K, capture_w2c) | |
| cloud_centre = cloud_xyz.mean(axis=0) if cloud_xyz.shape[0] else np.zeros(3) | |
| cam_origin = base_cam.cam_to_world(np.zeros((1, 3)))[0] | |
| orbit_distance = float(np.linalg.norm(cam_origin - cloud_centre)) | |
| link_meshes = robot.visual_meshes() if traj.joint_positions is not None else {} | |
| object_meshes = [asset.mesh for asset in object_assets] | |
| def _new_renderer() -> _ConditioningRenderer: | |
| return _ConditioningRenderer( | |
| link_meshes, object_meshes, args.width, args.height, bg_color=(0.0, 0.0, 0.0, 0.0), | |
| ) | |
| renderer = _new_renderer() | |
| seg_id_map: dict[str, str] = {"0": "background_scene", "1": "robot"} | |
| for i, name in enumerate(object_names): | |
| seg_id_map[str(2 + i)] = name | |
| seg_pixel_counts = np.zeros(2 + len(object_names), dtype=np.int64) | |
| world_to_cam_per_frame: list[list[list[float]]] = [] | |
| writer = VideoWriter(out_dir / "preview.mp4", fps=traj.fps) | |
| t_start = time.monotonic() | |
| try: | |
| for i in range(n_frames): | |
| if i > 0 and args.renderer_recycle > 0 and i % args.renderer_recycle == 0: | |
| renderer.close() | |
| renderer = _new_renderer() | |
| joints_i = traj.joint_positions[i] if traj.joint_positions is not None else None | |
| gripper_i = float(traj.gripper[i]) | |
| link_poses = robot.link_poses(joints_i, gripper_i) if joints_i is not None else {} | |
| poses_i = ( | |
| [traj.object_poses[i, j] for j in range(len(object_names))] | |
| if traj.object_poses is not None else [] | |
| ) | |
| if args.mode == "orbit": | |
| az = -args.orbit_degrees / 2.0 + args.orbit_degrees * i / max(n_frames - 1, 1) | |
| camera = free_camera(cloud_centre, orbit_distance, 180.0 + az, 18.0, K) | |
| else: | |
| camera = base_cam | |
| world_to_cam_per_frame.append(_world_to_cam_matrix(camera).tolist()) | |
| combined, robot_mask, object_masks = renderer.render(link_poses, poses_i, camera) | |
| uv, cloud_depth = camera.project(cloud_xyz) | |
| canvas_bgr = np.full((args.height, args.width, 3), rps._BG, dtype=np.uint8) | |
| canvas_bgr = rps._draw_points( | |
| canvas_bgr, uv, cloud_depth, cloud_rgb, combined.depth, args.point_radius | |
| ) | |
| canvas_bgr[combined.mask] = combined.color[:, :, ::-1][combined.mask] | |
| depth_m = _composite_depth(combined.depth, uv, cloud_depth, args.point_radius) | |
| depth_mm = np.clip(np.round(depth_m * 1000.0), 0, 65535).astype(np.uint16) | |
| seg = np.zeros((args.height, args.width), dtype=np.uint8) | |
| seg[robot_mask] = 1 | |
| for j, m in enumerate(object_masks): | |
| seg[m] = 2 + j | |
| seg_pixel_counts[0] += int((seg == 0).sum()) | |
| seg_pixel_counts[1] += int((seg == 1).sum()) | |
| for j in range(len(object_names)): | |
| seg_pixel_counts[2 + j] += int((seg == 2 + j).sum()) | |
| Image.fromarray(canvas_bgr[:, :, ::-1]).save(rgb_dir / f"{i:05d}.png") | |
| Image.fromarray(depth_mm).save(depth_dir / f"{i:05d}.png") | |
| Image.fromarray(seg).save(seg_dir / f"{i:05d}.png") | |
| writer.write(canvas_bgr) | |
| tcp_pose = _tcp_pose(link_poses) if link_poses else None | |
| frame_pose = { | |
| "frame": i, | |
| "timestamp_s": float(traj.timestamps[i]), | |
| "tcp_pose": tcp_pose.tolist() if tcp_pose is not None else None, | |
| "joint_positions": joints_i.tolist() if joints_i is not None else None, | |
| "gripper": gripper_i, | |
| "objects": { | |
| name: ( | |
| traj.object_poses[i, j].tolist() if traj.object_poses is not None else None | |
| ) | |
| for j, name in enumerate(object_names) | |
| }, | |
| } | |
| (pose_dir / f"{i:05d}.json").write_text(json.dumps(frame_pose, indent=2)) | |
| if i % 10 == 0 or i == n_frames - 1: | |
| elapsed = time.monotonic() - t_start | |
| logger.info( | |
| "frame %d/%d %.3fs/frame avg RSS=%dMB", | |
| i, n_frames, elapsed / (i + 1), _rss_mb(), | |
| ) | |
| finally: | |
| writer.close() | |
| renderer.close() | |
| total_elapsed = time.monotonic() - t_start | |
| if args.action_json is not None and args.action_json.exists(): | |
| action_payload: dict = json.loads(args.action_json.read_text()) | |
| action_payload.setdefault("_source_file", str(args.action_json)) | |
| else: | |
| action_payload = { | |
| "note": "no --action-json sidecar was provided (or the given path did not exist); " | |
| "this export has no recorded action spec.", | |
| "action_json_arg": str(args.action_json) if args.action_json else None, | |
| } | |
| (out_dir / "action.json").write_text(json.dumps(action_payload, indent=2, default=str)) | |
| camera_json = { | |
| "fx": K.fx, "fy": K.fy, "cx": K.cx, "cy": K.cy, "width": K.width, "height": K.height, | |
| "world_to_cam_per_frame": world_to_cam_per_frame, | |
| "capture_episode": args.episode, | |
| "capture_camera_role": args.camera, | |
| "capture_camera_serial": serial, | |
| "capture_clip": args.clip, | |
| "capture_trajectory_row": capture_row, | |
| "mode": args.mode, | |
| } | |
| (out_dir / "camera.json").write_text(json.dumps(camera_json, indent=2)) | |
| meta = { | |
| "frame_count": n_frames, | |
| "fps": traj.fps, | |
| "channels": { | |
| "rgb": "rgb/%05d.png, uint8 RGB (H, W, 3): robot+object mesh render composited " | |
| "over the carved background point cloud", | |
| "depth": "depth/%05d.png, uint16 (H, W) PNG, millimetres, 0 = no data. The " | |
| "renderer's mesh depth wherever the robot/object mesh is present (matching rgb " | |
| "and seg there), else the carved point-cloud depth, else 0.", | |
| "seg": "seg/%05d.png, uint8 (H, W) instance ids, see seg_id_map", | |
| "pose": "pose/%05d.json: tcp_pose (4x4 or null), joint_positions (7,) or null, " | |
| "gripper (float in [0, 1]), objects (name -> 4x4 world pose or null)", | |
| }, | |
| "seg_id_map": seg_id_map, | |
| "seg_pixel_counts": {k: int(v) for k, v in zip(seg_id_map, seg_pixel_counts, strict=True)}, | |
| "mode": args.mode, | |
| "renderer_recycle_every_n_frames": args.renderer_recycle, | |
| "runtime_seconds_total": total_elapsed, | |
| "runtime_seconds_per_frame_avg": total_elapsed / n_frames if n_frames else float("nan"), | |
| "input_warnings": traj.warnings, | |
| "object_assets": { | |
| asset.name: { | |
| "real_geometry": asset.is_real, | |
| "carved_from_background": asset.capture_transform is not None, | |
| } | |
| for asset in object_assets | |
| }, | |
| "stand_in_input": bool(action_payload.get("STAND_IN", False)), | |
| "limitations": { | |
| "no_dynamics": "Kinematic replay only: no mass, friction, or inertia is modelled. A " | |
| "grasp is a rigid attachment of the object to the gripper frame, not a contact/force " | |
| "simulation. Commanded speed changes only the timing of the motion, never its path.", | |
| "single_viewpoint_capture": "The background is a single-viewpoint 2.5D point cloud " | |
| "unprojected from one real depth frame. Surfaces the capture camera never saw have no " | |
| "points; rendering far from the capture pose (e.g. --mode orbit) exposes real holes in " | |
| "the data, not a rendering bug.", | |
| "no_collision_checking": "No collision checking was performed between the robot, the " | |
| "manipulated object(s), or the static scene, at any stage of producing this trajectory " | |
| "or this render.", | |
| "no_settling_at_release": "A released object keeps exactly the pose it was holding at " | |
| "release; it does not fall, tip, or settle onto its support. Combined with the rigid " | |
| "grasp above, that means a released object can end up resting on an edge or floating " | |
| "clear of the surface it was placed on -- see placement_geometry below when the " | |
| "planner reports it. Levelling the object at release would be the one place a " | |
| "physics step is genuinely warranted (and is mass-independent), and is deliberately " | |
| "not done here.", | |
| }, | |
| } | |
| # Surface the planner's own placement diagnostics rather than restating them: | |
| # if it measured a tilt it could not avoid, that number belongs next to the | |
| # limitation it illustrates, so a downstream consumer sees the defect's | |
| # magnitude and not just its existence. | |
| _place = action_payload.get("place_orientation_search") | |
| if isinstance(_place, dict) and "tilt_from_flat_deg" in _place: | |
| meta["placement_geometry"] = { | |
| "object_tilt_from_flat_deg": _place["tilt_from_flat_deg"], | |
| "why": "The placement target sits at the edge of the arm's reach and only converges " | |
| "with the wrist pitched off vertical; because the object is rigidly attached at its " | |
| "pick-time orientation, that pitch is transferred to the object and no reachable " | |
| "orientation lands it flat.", | |
| "orientation_search": _place, | |
| } | |
| (out_dir / "meta.json").write_text(json.dumps(meta, indent=2)) | |
| print(f"wrote {n_frames} frames -> {out_dir.resolve()}") | |
| print(f"runtime: {total_elapsed:.1f}s total, {total_elapsed / n_frames:.3f}s/frame avg") | |
| print(f"peak RSS: {_rss_mb()} MB") | |
| return 0 | |
| if __name__ == "__main__": | |
| raise SystemExit(main()) | |
Xet Storage Details
- Size:
- 36.8 kB
- Xet hash:
- d7a255855764c7c5eff79cbbb21a4a45d05554e2f9939605a2b8c84946a208ac
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.