Buckets:
| """Stage 5: render robot + object mesh + point cloud into one image, world frame. | |
| Everything here lives in the world (robot-base) frame established by stages 1-4: | |
| the robot is posed by forward kinematics, the object by | |
| :class:`~fpgm.objects.types.ObjectTrajectory`'s per-frame transform, and the | |
| observed point cloud is carried along unchanged from stage 3. Putting all three | |
| in one image is the entire point of reconstructing a mesh instead of drawing a | |
| 2D sprite: only a real mesh, rendered through a real depth buffer, can show | |
| which of the robot and the object is actually in front at a given pixel. | |
| :class:`SceneRenderer` does exactly that -- it reuses | |
| :class:`~fpgm.robot.render.RobotRenderer` wholesale (same persistent | |
| ``pyrender.Scene``, same OSMesa offscreen context) and adds the object as one | |
| more node in that same scene, so pyrender's own z-buffer resolves robot/object | |
| occlusion instead of any hand-rolled compositing order. | |
| The rest of this module (:func:`project_point_cloud`, :func:`draw_point_cloud`, | |
| :func:`free_camera`, :func:`state_color`, :func:`draw_scene_hud`) is pure numpy | |
| + OpenCV and needs no GL context at all. | |
| """ | |
| from __future__ import annotations | |
| import math | |
| from dataclasses import dataclass | |
| import numpy as np | |
| import trimesh | |
| from PIL import Image | |
| from fpgm.geometry.camera import Camera | |
| from fpgm.geometry.transforms import invert_se3 | |
| from fpgm.objects.types import InteractionState, ObjectMesh | |
| from fpgm.robot.render import RobotRenderer | |
| from fpgm.types import CameraIntrinsics | |
| from fpgm.utils.logging import get_logger | |
| from fpgm.viz.overlays import draw_hud | |
| logger = get_logger(__name__) | |
| # fpgm.robot.render (imported above) calls _configure_headless_gl() -- setting | |
| # PYOPENGL_PLATFORM=osmesa and stubbing pyrender.viewer -- as a side effect of | |
| # its own module import, *before* it imports pyrender itself. Importing it | |
| # first guarantees that has already happened by the time this line runs, so | |
| # this module never needs (and must never duplicate) that setup itself. | |
| import pyrender # noqa: E402 | |
| #: Robot-base (``panda_link0``) frame convention assumed throughout this | |
| #: module: +Z is up. This matches the physical Franka mount and is the only | |
| #: place that assumption is baked in -- see :func:`free_camera`. | |
| _WORLD_UP = np.array([0.0, 0.0, 1.0]) | |
| class SceneRenderResult: | |
| """One rendered frame of robot + object, from a single pyrender scene. | |
| ``robot_mask`` and ``object_mask`` are disjoint by construction -- see | |
| :meth:`SceneRenderer.render` for how they are computed. | |
| """ | |
| color: np.ndarray # (H, W, 3) uint8, RGB | |
| depth: np.ndarray # (H, W) float32, metres; 0 where nothing was hit | |
| robot_mask: np.ndarray # (H, W) bool | |
| object_mask: np.ndarray # (H, W) bool | |
| def mask(self) -> np.ndarray: | |
| """``robot_mask | object_mask``. | |
| Lets a :class:`SceneRenderResult` duck-type as a | |
| :class:`~fpgm.robot.render.RenderResult` for | |
| :func:`fpgm.robot.overlay.composite`, which only ever reads ``.color`` | |
| and ``.mask``. | |
| """ | |
| return self.robot_mask | self.object_mask | |
| class SceneRenderer: | |
| """Offscreen renderer for the robot AND its manipulated object, in one scene. | |
| All robot geometry/camera/lighting setup is delegated to | |
| :class:`~fpgm.robot.render.RobotRenderer` -- this class does not | |
| reimplement any of it, per that module's own admonition that scene | |
| construction (not per-frame rendering) dominates runtime on OSMesa's | |
| software rasteriser. The object gets exactly one extra node in | |
| ``RobotRenderer``'s persistent ``pyrender.Scene``, re-posed per frame | |
| exactly like a robot link, never rebuilt. | |
| **Mask strategy** (chosen deliberately; the alternative -- per-node colour | |
| tagging read back from the colour buffer -- was rejected because this | |
| OSMesa build does not reliably expose an object-id/alpha channel, see | |
| :class:`~fpgm.robot.render.RobotRenderer`'s own note on ``RenderFlags.RGBA``): | |
| 1. Render robot + object together (one pass) for the real composited | |
| image and combined depth -- this is the pass where occlusion between | |
| them is resolved correctly, by pyrender's own z-buffer. | |
| 2. Temporarily detach the robot's link nodes and render the object alone | |
| (second pass, same camera/light state) to get the object's own, | |
| unoccluded depth at every pixel it would cover if nothing were in front | |
| of it. | |
| 3. A pixel counts as "object" wherever the object-alone depth agrees with | |
| the combined depth (within :attr:`_MASK_DEPTH_ATOL`) -- i.e. the object | |
| really is the nearest surface there. Every other combined-mask pixel is | |
| "robot" -- with only two entities ever in the scene, that is exact, | |
| including at silhouette boundaries where the arm partially occludes the | |
| object. | |
| This costs one extra render call per frame (cheap relative to scene | |
| construction) and needs no changes to :class:`RobotRenderer` itself. | |
| """ | |
| #: Depth agreement tolerance (metres) between the object-alone pass and the | |
| #: combined pass, for classifying a pixel as "object visible here". | |
| _MASK_DEPTH_ATOL = 1e-4 | |
| def __init__( | |
| self, | |
| link_meshes: dict[str, list[tuple[trimesh.Trimesh, np.ndarray]]], | |
| object_mesh: ObjectMesh, | |
| width: int, | |
| height: int, | |
| *, | |
| object_color_rgb: tuple[int, int, int] = (220, 120, 60), | |
| **robot_kwargs: object, | |
| ) -> None: | |
| """Build the persistent robot+object scene graph. | |
| Args: | |
| link_meshes: Robot visual geometry -- see | |
| :class:`~fpgm.robot.render.RobotRenderer`. | |
| object_mesh: The manipulated object's mesh, in its own canonical | |
| frame (e.g. from :mod:`fpgm.objects.proxy` or TRELLIS). Re-posed | |
| per frame via :meth:`render`. | |
| width: Render width in pixels. | |
| height: Render height in pixels. | |
| object_color_rgb: Flat RGB fill used when `object_mesh` carries no | |
| per-vertex colour, so a proxy mesh is visually distinguishable | |
| from the robot rather than falling back to pyrender's default | |
| grey. | |
| **robot_kwargs: Forwarded to :class:`~fpgm.robot.render.RobotRenderer` | |
| (``ambient``, ``bg_color``, ``znear``, ``zfar``, | |
| ``light_intensity``). | |
| Raises: | |
| RenderError: If the OSMesa offscreen GL context cannot be created | |
| (raised by the underlying ``RobotRenderer``). | |
| """ | |
| self._robot = RobotRenderer(link_meshes, width, height, **robot_kwargs) | |
| self.width = self._robot.width | |
| self.height = self._robot.height | |
| pmesh = self._build_object_pyrender_mesh(object_mesh, object_color_rgb) | |
| self._object_node = pyrender.Node(mesh=pmesh, matrix=np.eye(4)) | |
| self._robot.scene.add_node(self._object_node) | |
| # Flat list of every robot link's pyrender.Node -- toggled off/on for | |
| # the object-alone mask pass in render(); see the class docstring. | |
| # Reaches into RobotRenderer's "private" _link_nodes/_renderer rather | |
| # than duplicating its scene-graph bookkeeping, which is exactly the | |
| # integration point RobotRenderer's own docstring points at (build the | |
| # scene once, only update poses per frame). | |
| self._link_nodes = [ | |
| node for entries in self._robot._link_nodes.values() for node, _ in entries | |
| ] | |
| def _build_object_pyrender_mesh( | |
| object_mesh: ObjectMesh, object_color_rgb: tuple[int, int, int] | |
| ) -> pyrender.Mesh: | |
| # process=False: a textured mesh's `uv` is per-vertex, so trimesh's | |
| # default vertex welding would desync a UV row from the vertex it | |
| # belongs to (same reason scripts/run_object_pipeline.py's mesh save | |
| # /load round-trip disables it). | |
| if object_mesh.uv is not None and object_mesh.texture is not None: | |
| visual = trimesh.visual.TextureVisuals( | |
| uv=np.asarray(object_mesh.uv, dtype=np.float32), | |
| image=Image.fromarray(np.asarray(object_mesh.texture, dtype=np.uint8)), | |
| ) | |
| tri = trimesh.Trimesh( | |
| vertices=np.asarray(object_mesh.vertices, dtype=np.float64), | |
| faces=np.asarray(object_mesh.faces, dtype=np.int64), | |
| visual=visual, | |
| process=False, | |
| ) | |
| # Deliberately no vertex_colors assignment on this branch: setting | |
| # `tri.visual.vertex_colors` replaces `visual` with a fresh | |
| # ColorVisuals wholesale (trimesh visuals are one-kind-at-a-time), | |
| # which is exactly how the texture used to get silently thrown | |
| # away here even after it was loaded correctly. | |
| else: | |
| tri = trimesh.Trimesh( | |
| vertices=np.asarray(object_mesh.vertices, dtype=np.float64), | |
| faces=np.asarray(object_mesh.faces, dtype=np.int64), | |
| process=False, | |
| ) | |
| if object_mesh.vertex_colors is not None: | |
| tri.visual.vertex_colors = object_mesh.vertex_colors | |
| else: | |
| fill = np.array([*object_color_rgb, 255], dtype=np.uint8) | |
| tri.visual.vertex_colors = np.tile(fill, (len(tri.vertices), 1)) | |
| # smooth=False: consistent with RobotRenderer's own meshes (see its | |
| # docstring) and harmless here even without per-face colours. | |
| return pyrender.Mesh.from_trimesh(tri, smooth=False) | |
| def render( | |
| self, | |
| link_poses: dict[str, np.ndarray], | |
| object_pose: np.ndarray, | |
| camera: Camera, | |
| ) -> SceneRenderResult: | |
| """Render one frame: robot posed by `link_poses`, object posed by `object_pose`. | |
| Args: | |
| link_poses: See :meth:`~fpgm.robot.render.RobotRenderer.render`. | |
| object_pose: ``(4, 4)`` mesh-canonical -> world transform for this | |
| frame, e.g. one row of | |
| :attr:`~fpgm.objects.types.ObjectTrajectory.transforms`. | |
| camera: Camera to render from; must match this renderer's resolution. | |
| Returns: | |
| A :class:`SceneRenderResult` with disjoint ``robot_mask``/``object_mask``. | |
| Raises: | |
| ValueError: If `camera`'s resolution does not match this renderer's. | |
| KeyError: If `link_poses` is missing a link this renderer has geometry for. | |
| """ | |
| self._robot.scene.set_pose(self._object_node, np.asarray(object_pose, dtype=np.float64)) | |
| combined = self._robot.render(link_poses, camera) | |
| for node in self._link_nodes: | |
| self._robot.scene.remove_node(node) | |
| try: | |
| _, obj_depth = self._robot._renderer.render(self._robot.scene) | |
| finally: | |
| for node in self._link_nodes: | |
| self._robot.scene.add_node(node) | |
| obj_depth = np.asarray(obj_depth, dtype=np.float32) | |
| combined_mask = combined.mask | |
| object_mask = ( | |
| combined_mask | |
| & (obj_depth > 0.0) | |
| & (np.abs(obj_depth - combined.depth) <= self._MASK_DEPTH_ATOL) | |
| ) | |
| robot_mask = combined_mask & ~object_mask | |
| return SceneRenderResult( | |
| color=combined.color, | |
| depth=combined.depth, | |
| robot_mask=robot_mask, | |
| object_mask=object_mask, | |
| ) | |
| def close(self) -> None: | |
| """Release the underlying GL context. Safe to call more than once.""" | |
| self._robot.close() | |
| def __enter__(self) -> SceneRenderer: | |
| return self | |
| def __exit__(self, *exc_info: object) -> None: | |
| self.close() | |
| def project_point_cloud( | |
| points_world: np.ndarray, | |
| colors: np.ndarray, | |
| camera: Camera, | |
| image_shape: tuple[int, int], | |
| ) -> tuple[np.ndarray, np.ndarray]: | |
| """Project a world-frame point cloud to pixels, ordered nearest-drawn-last. | |
| Points behind the camera or outside `image_shape` are dropped -- there is | |
| nothing meaningful to draw for them. The rest are sorted by depth, | |
| farthest first, so a caller drawing them in the returned order paints | |
| nearer points last (i.e. on top), which is a correct occlusion order for | |
| dots with no size/depth-buffer of their own. | |
| Args: | |
| points_world: ``(N, 3)`` float, world-frame points. | |
| colors: ``(N, 3)`` uint8, one colour per point, same order as | |
| `points_world`. | |
| camera: Camera to project through. | |
| image_shape: ``(H, W)`` of the target image. | |
| Returns: | |
| ``(uv, colors_sorted)``: ``(M, 2)`` float pixel coordinates and | |
| ``(M, 3)`` uint8 colours, ``M <= N``, ordered far-to-near. | |
| Raises: | |
| ValueError: If `points_world` and `colors` disagree on point count. | |
| """ | |
| points_world = np.asarray(points_world, dtype=np.float64) | |
| colors = np.asarray(colors) | |
| if points_world.shape[0] != colors.shape[0]: | |
| raise ValueError( | |
| f"project_point_cloud: {points_world.shape[0]} points but " | |
| f"{colors.shape[0]} colours" | |
| ) | |
| uv, depth = camera.project(points_world) | |
| height, width = image_shape[0], image_shape[1] | |
| in_front = depth > 0.0 | |
| in_bounds = (uv[:, 0] >= 0) & (uv[:, 0] < width) & (uv[:, 1] >= 0) & (uv[:, 1] < height) | |
| keep = in_front & in_bounds | |
| uv, depth, colors = uv[keep], depth[keep], colors[keep] | |
| order = np.argsort(depth)[::-1] # descending depth: farthest first, nearest last | |
| return uv[order], colors[order] | |
| def draw_point_cloud( | |
| frame_bgr: np.ndarray, uv: np.ndarray, colors: np.ndarray, radius: int = 1 | |
| ) -> np.ndarray: | |
| """Draw projected point-cloud dots onto a frame. Pure cv2, no GL involved. | |
| Args: | |
| frame_bgr: ``(H, W, 3)`` uint8 BGR frame. | |
| uv: ``(N, 2)`` pixel coordinates, typically from | |
| :func:`project_point_cloud` (already ordered so nearer points draw | |
| last / on top). | |
| colors: ``(N, 3)`` uint8 colours, interpreted as RGB (matching | |
| :attr:`~fpgm.objects.types.ObjectAlignment.point_colors` and | |
| pyrender's RGB convention) and converted to BGR for drawing. | |
| radius: Dot radius in pixels. | |
| Returns: | |
| A copy of `frame_bgr` with the dots drawn; the input is not mutated. | |
| """ | |
| import cv2 | |
| out = frame_bgr.copy() | |
| uv = np.asarray(uv) | |
| colors = np.asarray(colors) | |
| for i in range(uv.shape[0]): | |
| center = tuple(np.round(uv[i]).astype(int)) | |
| color_bgr = (int(colors[i, 2]), int(colors[i, 1]), int(colors[i, 0])) | |
| cv2.circle(out, center, radius, color_bgr, -1, lineType=cv2.LINE_AA) | |
| return out | |
| def free_camera( | |
| look_at: np.ndarray, | |
| distance: float, | |
| azimuth_deg: float, | |
| elevation_deg: float, | |
| intrinsics: CameraIntrinsics, | |
| ) -> Camera: | |
| """Build a synthetic camera orbiting `look_at` at a fixed distance. | |
| The recorded (episode) camera cannot show whether the reconstructed object | |
| is correctly placed *in depth* -- a wrong depth still looks plausible from | |
| the one viewpoint that generated it. A second, independent viewpoint of the | |
| same world is the only honest way to see that 3D relationship, which is | |
| what this is for. | |
| World up is assumed to be ``+Z`` (the ``panda_link0`` convention this whole | |
| pipeline works in -- see the module docstring). | |
| Args: | |
| look_at: ``(3,)`` world-frame point the camera points at. | |
| distance: Orbit radius, metres. | |
| azimuth_deg: Rotation around the world +Z axis, degrees. 0 puts the | |
| camera on the world +X side of `look_at`. | |
| elevation_deg: Angle above the horizontal plane through `look_at`, | |
| degrees. 0 is level, 90 is looking straight down. | |
| intrinsics: Pinhole intrinsics for the returned camera. | |
| Returns: | |
| A :class:`~fpgm.geometry.camera.Camera` positioned on the orbit and | |
| looking exactly at `look_at`. | |
| """ | |
| look_at = np.asarray(look_at, dtype=np.float64).reshape(3) | |
| az, el = math.radians(azimuth_deg), math.radians(elevation_deg) | |
| offset = distance * np.array( | |
| [math.cos(el) * math.cos(az), math.cos(el) * math.sin(az), math.sin(el)] | |
| ) | |
| cam_pos = look_at + offset | |
| forward = look_at - cam_pos | |
| forward_norm = np.linalg.norm(forward) | |
| if forward_norm < 1e-9: | |
| raise ValueError("free_camera: distance must be > 0") | |
| forward = forward / forward_norm | |
| right = np.cross(forward, _WORLD_UP) | |
| right_norm = np.linalg.norm(right) | |
| if right_norm < 1e-6: | |
| # Looking straight up/down world +Z: world_up gives no constraint on | |
| # "right", so fall back to a fixed reference axis instead. | |
| fallback = np.array([1.0, 0.0, 0.0]) | |
| right = np.cross(forward, fallback) | |
| right_norm = np.linalg.norm(right) | |
| if right_norm < 1e-6: | |
| fallback = np.array([0.0, 1.0, 0.0]) | |
| right = np.cross(forward, fallback) | |
| right_norm = np.linalg.norm(right) | |
| right = right / right_norm | |
| down = np.cross(forward, right) # already unit: forward, right orthonormal | |
| cam_to_world = np.eye(4, dtype=np.float64) | |
| cam_to_world[:3, 0] = right | |
| cam_to_world[:3, 1] = down | |
| cam_to_world[:3, 2] = forward | |
| cam_to_world[:3, 3] = cam_pos | |
| world_to_cam = invert_se3(cam_to_world) | |
| return Camera(intrinsics, world_to_cam) | |
| #: BGR colours (cv2 convention), one per InteractionState, chosen for maximum | |
| #: separation: grey (nothing happening), amber (in contact), crimson (attached). | |
| _STATE_COLORS_BGR: dict[InteractionState, tuple[int, int, int]] = { | |
| InteractionState.FREE: (150, 150, 150), | |
| InteractionState.PUSHED: (0, 165, 255), | |
| InteractionState.GRASPED: (40, 30, 220), | |
| } | |
| def state_color(state: InteractionState) -> tuple[int, int, int]: | |
| """Stable BGR colour for an :class:`~fpgm.objects.types.InteractionState`.""" | |
| return _STATE_COLORS_BGR[state] | |
| def draw_scene_hud( | |
| frame_bgr: np.ndarray, | |
| frame_idx: int, | |
| timestamp_s: float, | |
| state: InteractionState, | |
| object_speed_mps: float, | |
| push_gain: float, | |
| origin: tuple[int, int] = (12, 12), | |
| ) -> np.ndarray: | |
| """Draw the stage-5 HUD: frame/time, interaction state, object speed, push_gain. | |
| `push_gain` is drawn as a loud, explicit warning line whenever it is not | |
| ``1.0`` -- pushed motion is deliberately amplified for visibility (see | |
| :class:`fpgm.objects.interaction.InteractionConfig`), and that amplified | |
| motion must never be mistaken for a physical measurement. | |
| Args: | |
| frame_bgr: ``(H, W, 3)`` uint8 BGR frame to draw over. | |
| frame_idx: Frame index, for the HUD text. | |
| timestamp_s: Frame timestamp, seconds. | |
| state: This frame's :class:`~fpgm.objects.types.InteractionState`. | |
| object_speed_mps: Object speed at this frame, metres/second. | |
| push_gain: The trajectory's | |
| :attr:`~fpgm.objects.types.ObjectTrajectory.push_gain`. | |
| origin: Top-left corner of the HUD panel, pixels. | |
| Returns: | |
| A copy of `frame_bgr` with the HUD drawn on. | |
| """ | |
| lines = [ | |
| f"frame={frame_idx} t={timestamp_s:.2f}s", | |
| f"state={state.value}", | |
| f"object speed = {object_speed_mps:.3f} m/s", | |
| ] | |
| if not math.isclose(push_gain, 1.0): | |
| lines.append(f"!! push_gain={push_gain:.1f}x -- VISUALISATION ONLY, not physical !!") | |
| return draw_hud(frame_bgr, lines, origin=origin) | |
Xet Storage Details
- Size:
- 19.5 kB
- Xet hash:
- 15bb986c361907c632f79f3260bcd3df06f45a20f27fa1f045af73a26c1b0ace
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.