twanghcmut's picture
download
raw
45.2 kB
#!/usr/bin/env python
"""Staged object reconstruction + interaction pipeline (stages 1-5), the debugging backbone.
PYTHONPATH=src python scripts/run_object_pipeline.py \\
--episode <uuid> --camera ext1 --clip 10:21 --stages mask,mesh,align,act,render
Five stages, each independently re-runnable from the previous stage's artefacts
on disk (see :mod:`fpgm.objects.types`'s module docstring):
1. mask : SAM 3.1 text-prompted segmentation -> tight RGBA crop + the
frame-0 mask (frame 0 is the only frame this dataset gives us
dense depth for, via ``initial_depth``/``initial_rgb``).
2. mesh : crop -> mesh, either TRELLIS or SAM 3D Objects (both externally
generated, passed via ``--glb``) or a proxy fitted to the
observed point cloud.
3. align : mask x depth -> world point cloud -> scale + 6-DOF mesh pose.
4. act : robot FK -> per-frame object pose (free / pushed / grasped).
5. render : robot + object + point cloud, composited in the world frame,
from both the recorded camera and a synthetic orbiting one.
Each stage writes into ``outputs/<uuid>/objects/<n>_<stage>/`` and a later
stage always *loads* its inputs from there rather than recomputing them --
run e.g. only ``--stages render`` once ``mask``, ``mesh``, ``align`` and
``act`` already have artefacts on disk. A missing upstream artefact fails with
the exact command to produce it, not a stack trace.
Example, once ``mask``/``mesh``/``align``/``act`` have already been run:
PYTHONPATH=src python scripts/run_object_pipeline.py \\
--episode <uuid> --camera ext1 --clip 10:21 --stages render --push-gain 8.0
"""
from __future__ import annotations
import argparse
import json
import sys
from dataclasses import dataclass
from pathlib import Path
import cv2
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"))
from fpgm.data.droid_raw import cam2world_vector_to_world2cam, read_mp4_properties # noqa: E402
from fpgm.data.pointworld import FlowsReader, verify_annotation_stride # noqa: E402
from fpgm.geometry.camera import Camera # noqa: E402
from fpgm.objects.types import ( # noqa: E402
InteractionState,
MeshSource,
ObjectAlignment,
ObjectMesh,
ObjectTrajectory,
StageArtifacts,
)
from fpgm.pipeline.frames import ClipFrameSource # noqa: E402
from fpgm.types import ClipTiming, DataError, SceneFlowClip # 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
logger = get_logger("run_object_pipeline")
STAGES: tuple[str, ...] = ("mask", "mesh", "align", "act", "render")
STAGE_INDEX: dict[str, int] = {s: i + 1 for i, s in enumerate(STAGES)}
_DEFAULT_URDF = (
REPO_ROOT
/ "third_party"
/ "robot_description"
/ "pointworld_franka_robotiq_2f85"
/ "franka_panda_robotiq_2f85.urdf"
)
#: DROID's nominal 15 Hz control rate. The public raw release does not embed
#: the real one in trajectory.h5 (see fpgm.data.episode's own note on this),
#: so this fallback is the *expected* path here, not a last resort.
_TRAJECTORY_FPS = 15.0
#: Neutral background for the free-camera video, which has no real video plate
#: behind it (the recorded camera never saw this synthetic viewpoint).
_FREECAM_BG = (40, 40, 40)
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
p.add_argument("--episode", required=True, help="DROID episode uuid")
p.add_argument("--camera", default="ext1", choices=["ext1", "ext2"])
p.add_argument(
"--clip", required=True, help='PointWorld scene-flow clip key, e.g. "10:21"'
)
p.add_argument(
"--stages",
default=",".join(STAGES),
help=f"comma-separated stages to run, in order; any subset of {STAGES}",
)
p.add_argument(
"--mesh-source",
default="proxy_box",
choices=["trellis", "sam3d", "proxy_box", "proxy_hull"],
help="mesh stage source; defaults to a fitted proxy box until a real "
"generator's output is supplied. 'sam3d' is facebook/sam-3d-objects "
"(see scripts/sam3d_generate.py) -- as of this writing its checkpoint "
"access is pending review, so this path is untested end-to-end; the "
"code is complete and should work once a --glb is produced.",
)
p.add_argument(
"--glb", type=Path, default=None,
help="externally generated mesh (from TRELLIS or SAM 3D Objects, produced by "
"scripts/trellis_generate.py or scripts/sam3d_generate.py respectively); "
"required with --mesh-source trellis or --mesh-source sam3d",
)
p.add_argument(
"--push-gain", type=float, default=4.0,
help="visualisation-only amplification of pushed motion (act stage); 1.0 = physical",
)
p.add_argument(
"--outputs", type=Path, default=None,
help="output directory (default: outputs/<episode-uuid>)",
)
p.add_argument("--urdf", type=Path, default=_DEFAULT_URDF, help="robot URDF path")
p.add_argument(
"--device", default="cuda", help="torch device for SAM 3.1 (mask stage only)"
)
p.add_argument("--seed", type=int, default=0, help="RNG seed (mesh-fit ICP, sampling)")
p.add_argument(
"--freecam-distance", type=float, default=None,
help="orbit radius, metres (render stage); default: 3x the object's extent",
)
p.add_argument(
"--freecam-elevation", type=float, default=20.0,
help="orbit elevation above horizontal, degrees (render stage)",
)
return p.parse_args()
# --------------------------------------------------------------------------- #
# Shared data access -- every stage re-derives what it needs from raw episode
# data rather than trusting an earlier stage's in-memory state, so each one
# genuinely stands alone (see the module docstring).
# --------------------------------------------------------------------------- #
def _episode_dir(uuid: str) -> Path:
return REPO_ROOT / "data" / "droid_raw" / uuid
def _flows_path(uuid: str) -> Path:
return REPO_ROOT / "data" / "pointworld" / "droid" / "flows-fs-optimized" / f"{uuid}_flows.h5"
def _load_metadata(uuid: str) -> dict:
path = _episode_dir(uuid) / "metadata.json"
if not path.exists():
raise DataError(
f"no local DROID metadata for episode {uuid!r} at {path}; fetch it first with "
f"`python scripts/download_droid_episodes.py --episode {uuid}`."
)
return json.loads(path.read_text())
def _camera_serial(metadata: dict, role: str) -> str:
serial = metadata.get(f"{role}_cam_serial")
if serial is None:
raise DataError(f"episode metadata has no {role}_cam_serial entry")
return serial
@dataclass
class ClipContext:
"""Everything derived from raw episode data that every stage needs."""
clip: SceneFlowClip
camera_annot: Camera # native annotation resolution -- matches initial_depth/initial_rgb
mp4_path: Path
serial: str
#: Clock reconciliation for this clip, including the *measured* annotation
#: clip-frame -> trajectory/video-row stride. See :class:`fpgm.types.ClipTiming`.
timing: ClipTiming
def _build_timing(uuid: str, clip: SceneFlowClip) -> ClipTiming:
"""Measure ``clip``'s annotation-frame -> trajectory-row stride from real data.
Hardcoding the previously-discovered stride of 2 would repeat exactly the
mistake that produced the original bug -- see
:func:`fpgm.data.pointworld.verify_annotation_stride` and
:class:`fpgm.types.ClipTiming`'s docstring -- so every clip proves it against
its own ``joint_positions`` and this episode's own ``trajectory.h5``.
"""
if clip.joint_positions is None:
raise DataError(
f"clip {clip.key} has no joint_positions; cannot verify its "
"annotation_stride against trajectory.h5"
)
traj_path = _load_trajectory_h5(uuid)
with h5py.File(traj_path, "r") as f:
trajectory_joint_positions = np.asarray(f["observation/robot_state/joint_positions"])
stride = verify_annotation_stride(clip.joint_positions, trajectory_joint_positions, clip.start)
return ClipTiming(
clip_start_frame=clip.start,
clip_end_frame=clip.end,
trajectory_fps=_TRAJECTORY_FPS,
annotation_stride=stride,
)
def _strided_rows(start: int, n_frames: int, stride: int, available: int, what: str) -> np.ndarray:
"""Row/frame indices ``start*stride + stride*[0..n_frames)``, clipped to ``available``.
A mapped range is now up to ``stride`` times wider than the clip's own frame
count, so running off the end of a source array (``trajectory.h5``, the mp4) is
a real possibility, not just defensive boilerplate -- e.g. clip ``"50:61"`` maps
to trajectory/video rows ``[100, 122)``, which is within a 127-frame episode
but only just. Rather than raising, the axis is truncated to what actually
exists and a warning is logged, matching how :class:`~fpgm.pipeline.frames.
ClipFrameSource` already handles a video shorter than the requested range.
"""
rows = start * stride + stride * np.arange(n_frames)
in_range = rows < available
if not np.all(in_range):
logger.warning(
"%s: mapped range needs rows up to %d but only %d available "
"(stride=%d); truncating from %d to %d frames",
what,
int(rows[-1]),
available,
stride,
n_frames,
int(in_range.sum()),
)
rows = rows[in_range]
return rows
def _load_clip_context(uuid: str, camera_role: str, clip_key: str) -> ClipContext:
metadata = _load_metadata(uuid)
serial = _camera_serial(metadata, camera_role)
mp4_path = _episode_dir(uuid) / "recordings" / "MP4" / f"{serial}.mp4"
if not mp4_path.exists():
raise DataError(f"mp4 not found for camera {serial!r}: {mp4_path}")
flows_path = _flows_path(uuid)
if not flows_path.exists():
raise DataError(f"PointWorld flows.h5 not found for episode {uuid!r} at {flows_path}")
with FlowsReader(flows_path, episode_uuid=uuid) as reader:
available = reader.clip_keys()
if clip_key not in available:
raise DataError(
f"clip {clip_key!r} not found in {flows_path.name}; available clips: {available}"
)
clip = reader.read_clip(clip_key, serial)
if clip.initial_rgb is None:
raise DataError(f"clip {clip_key} has no initial_rgb to calibrate the camera against")
annot_h, annot_w = clip.initial_rgb.shape[:2]
camera_annot = Camera.from_pointworld(
clip.intrinsic, clip.extrinsic, width=annot_w, height=annot_h
)
timing = _build_timing(uuid, clip)
return ClipContext(
clip=clip, camera_annot=camera_annot, mp4_path=mp4_path, serial=serial, timing=timing
)
def _load_initial_depth(uuid: str, clip_key: str, serial: str) -> np.ndarray:
"""``(H, W)`` uint16 mm depth, present per-camera in flows.h5 but not (yet)
exposed on :class:`~fpgm.types.SceneFlowClip`, so it is read directly here."""
with h5py.File(_flows_path(uuid), "r") as f:
return np.asarray(f[clip_key][f"camera_{serial}_ext"]["initial_depth"])
class _DepthRgbAdapter:
"""Satisfies :class:`fpgm.objects.align.DepthRgbSource` without widening
the shared :class:`~fpgm.types.SceneFlowClip` contract for one stage."""
def __init__(self, initial_depth: np.ndarray, initial_rgb: np.ndarray | None) -> None:
self.initial_depth = initial_depth
self.initial_rgb = initial_rgb
def _load_trajectory_h5(uuid: str) -> Path:
path = _episode_dir(uuid) / "trajectory.h5"
if not path.exists():
raise DataError(f"trajectory.h5 not found: {path}")
return path
# --------------------------------------------------------------------------- #
# Stage plumbing: directories, and the "produce it first" failure message.
# --------------------------------------------------------------------------- #
def stage_dir(outputs_root: Path, stage: str) -> Path:
return outputs_root / "objects" / f"{STAGE_INDEX[stage]}_{stage}"
def _cmd_for(args: argparse.Namespace, stages: str) -> str:
parts = [
"PYTHONPATH=src", "python", "scripts/run_object_pipeline.py",
"--episode", args.episode, "--camera", args.camera, "--clip", args.clip,
"--stages", stages,
]
if args.mesh_source != "proxy_box":
parts += ["--mesh-source", args.mesh_source]
if args.glb:
parts += ["--glb", str(args.glb)]
return " ".join(parts)
def _require(path: Path, stage: str, args: argparse.Namespace, upstream_stages: str) -> Path:
if not path.exists():
raise DataError(
f"{stage} stage: missing required input {path}.\n"
f"Produce it first with:\n {_cmd_for(args, upstream_stages)}"
)
return path
# --------------------------------------------------------------------------- #
# Stage 1: mask
# --------------------------------------------------------------------------- #
def run_mask(args: argparse.Namespace, outputs_root: Path) -> StageArtifacts:
from fpgm.config import SegmentationConfig
from fpgm.objects import crop
from fpgm.pipeline.velocity_pipeline import _prompt_variants
from fpgm.prompting.task_prompt import TaskPromptDeriver
from fpgm.segmentation.sam3 import Sam3VideoSegmenter
out_dir = ensure_dir(stage_dir(outputs_root, "mask"))
ctx = _load_clip_context(args.episode, args.camera, args.clip)
metadata = _load_metadata(args.episode)
task = metadata.get("current_task", "")
# Reuse the velocity pipeline's candidate expansion rather than the bare head
# noun: on this data SAM 3.1 returns nothing for "brick" but scores 0.93 for
# "blue block", so the colour-qualified variants are the normal path.
derived = TaskPromptDeriver().derive(task)
candidates: list[str] = []
for phrase in [derived.primary, *derived.alternatives]:
for variant in _prompt_variants(phrase):
if variant and variant not in candidates:
candidates.append(variant)
logger.info("clip %s: task=%r -> prompt candidates %s", args.clip, task, candidates)
seg_cfg = SegmentationConfig()
segmenter = Sam3VideoSegmenter(seg_cfg, device=args.device)
# The clip's declared [start, end) is in the annotation clock, not the
# video's -- map through ctx.timing (measured annotation_stride) so SAM sees
# exactly the video frames the annotations describe, stride included.
video_start = int(ctx.timing.clip_frame_to_video_frame(0))
video_end = int(ctx.timing.clip_frame_to_video_frame(ctx.timing.n_frames))
with ClipFrameSource(
str(ctx.mp4_path), video_start, video_end, stride=ctx.timing.annotation_stride
) as frames:
width, height = frames.resolution
frame0_bgr = frames.read(0)
cv2.imwrite(str(out_dir / "frame0_bgr.png"), frame0_bgr)
masklets = None
obj_id: int | None = None
used_prompt = ""
for prompt in candidates:
segmenter.start_session(str(frames.frame_dir))
try:
segmenter.add_text_prompt(seg_cfg.prompt_frame_idx, prompt)
found = segmenter.collect_masklets()
if not found:
logger.info("prompt %r matched nothing; trying next candidate", prompt)
continue
obj_id = segmenter.select_object(found)
masklets, used_prompt = found, prompt
break
finally:
segmenter.close_session()
if masklets is None or obj_id is None:
raise DataError(f"mask stage: SAM 3.1 found nothing for any of {candidates}")
masklet = masklets[obj_id]
if 0 not in masklet.frames or not masklet.frames[0].any():
raise DataError(
f"mask stage: object mask is empty on frame 0 of clip {args.clip}; frame 0 "
"is the only frame with dense depth (initial_depth), so reconstruction "
"cannot proceed without it -- try a different --clip"
)
mask_frame0 = masklet.frames[0]
best_frame = crop.select_best_frame(masklet, prefer="largest")
best_frame_bgr = frames.read(best_frame)
obj_crop = crop.crop_from_mask(
best_frame_bgr, masklet.frames[best_frame], frame_idx=best_frame, prompt=used_prompt
)
artifacts = crop.save_debug(obj_crop, out_dir, frame_bgr=best_frame_bgr)
np.savez_compressed(out_dir / "mask_frame0.npz", mask=mask_frame0)
meta = {
"episode": args.episode,
"camera": args.camera,
"camera_serial": ctx.serial,
"clip": args.clip,
"prompt": used_prompt,
"obj_id": int(obj_id),
"video_resolution": [width, height],
"best_frame": int(best_frame),
"n_masklets": len(masklets),
}
(out_dir / "meta.json").write_text(json.dumps(meta, indent=2))
artifacts.add("mask_frame0", out_dir / "mask_frame0.npz")
artifacts.add("frame0_bgr", out_dir / "frame0_bgr.png")
artifacts.add("meta", out_dir / "meta.json")
artifacts.stats.update(meta)
return artifacts
def _load_mask_meta(outputs_root: Path) -> dict:
return json.loads((stage_dir(outputs_root, "mask") / "meta.json").read_text())
def _load_mask_frame0(outputs_root: Path) -> np.ndarray:
with np.load(stage_dir(outputs_root, "mask") / "mask_frame0.npz") as npz:
return npz["mask"].astype(bool)
# --------------------------------------------------------------------------- #
# Stage 2: mesh
# --------------------------------------------------------------------------- #
def _visual_to_mesh_fields(visual: trimesh.visual.base.Visuals) -> tuple[
np.ndarray | None, np.ndarray | None, np.ndarray | None
]:
"""Pull ``(vertex_colors, texture, uv)`` out of a loaded trimesh ``visual``.
A textured GLB (TRELLIS/SAM 3D output) loads as ``TextureVisuals``, which
has no ``vertex_colors`` attribute at all -- ``getattr(visual,
"vertex_colors", None)`` silently returns ``None`` for it, which used to
make this loader throw away both the texture AND any colour on every
textured mesh (SAM3D/TRELLIS bricks rendered flat grey as a result). The
``kind == "texture"`` branch below is what actually keeps the texture;
``to_color().vertex_colors`` in the fallback branch is only there so a
non-textured mesh (proxy hull/box, or a bare vertex-coloured GLB) still
keeps *some* colour instead of silently losing it the same way.
"""
vertex_colors: np.ndarray | None = None
texture: np.ndarray | None = None
uv: np.ndarray | None = None
if getattr(visual, "kind", None) == "texture":
uv = np.asarray(visual.uv, dtype=np.float32) if visual.uv is not None else None
base_color = getattr(visual.material, "baseColorTexture", None)
if base_color is not None:
texture = np.asarray(base_color.convert("RGB"), dtype=np.uint8)
if uv is None or texture is None:
# A "texture" visual with no actual image/uv (e.g. material-only,
# no baked pixels) -- fall back to flat colour rather than pass
# along a half-populated (uv, texture) pair that render code would
# have to special-case.
uv, texture = None, None
colors = visual.to_color().vertex_colors
if colors is not None and len(colors):
vertex_colors = np.asarray(colors)[:, :3].astype(np.uint8)
else:
colors = visual.to_color().vertex_colors
if colors is not None and len(colors):
vertex_colors = np.asarray(colors)[:, :3].astype(np.uint8)
return vertex_colors, texture, uv
def _save_mesh(mesh: ObjectMesh, out_dir: Path) -> None:
# process=False everywhere in this function: trimesh's default vertex
# merging/welding would change which row of `mesh.uv` corresponds to which
# vertex, silently misaligning the texture from the geometry it was baked
# onto.
if mesh.uv is not None and mesh.texture is not None:
visual = trimesh.visual.TextureVisuals(
uv=np.asarray(mesh.uv, dtype=np.float32),
image=Image.fromarray(np.asarray(mesh.texture, dtype=np.uint8)),
)
tri = trimesh.Trimesh(
vertices=np.asarray(mesh.vertices, dtype=np.float64),
faces=np.asarray(mesh.faces, dtype=np.int64),
visual=visual,
process=False,
)
else:
tri = trimesh.Trimesh(
vertices=np.asarray(mesh.vertices, dtype=np.float64),
faces=np.asarray(mesh.faces, dtype=np.int64),
process=False,
)
if mesh.vertex_colors is not None:
tri.visual.vertex_colors = mesh.vertex_colors
tri.export(str(out_dir / "mesh.glb"))
meta: dict = {"source": mesh.source.value}
npz_payload: dict[str, np.ndarray] = {}
for key, value in mesh.metadata.items():
if isinstance(value, np.ndarray):
npz_payload[key] = value
else:
meta[key] = value
if npz_payload:
np.savez_compressed(out_dir / "mesh_metadata.npz", **npz_payload)
(out_dir / "mesh_meta.json").write_text(json.dumps(meta, indent=2))
def _load_mesh(mesh_dir: Path) -> ObjectMesh:
meta = json.loads((mesh_dir / "mesh_meta.json").read_text())
# force="mesh" is not the problem here (it preserves TextureVisuals, see
# trimesh/visual/objects.py); process=False is what matters, for the same
# vertex/UV-correspondence reason as in _save_mesh.
loaded = trimesh.load(str(mesh_dir / "mesh.glb"), force="mesh", process=False)
vertex_colors, texture, uv = _visual_to_mesh_fields(loaded.visual)
metadata = {k: v for k, v in meta.items() if k != "source"}
npz_path = mesh_dir / "mesh_metadata.npz"
if npz_path.exists():
with np.load(npz_path) as npz:
metadata.update({k: npz[k] for k in npz.files})
return ObjectMesh(
vertices=np.asarray(loaded.vertices, dtype=np.float64),
faces=np.asarray(loaded.faces, dtype=np.int64),
source=MeshSource(meta["source"]),
vertex_colors=vertex_colors,
texture=texture,
uv=uv,
metadata=metadata,
)
def run_mesh(args: argparse.Namespace, outputs_root: Path) -> StageArtifacts:
from fpgm.objects.align import object_point_cloud
from fpgm.objects.proxy import convex_hull_mesh, oriented_box_mesh
out_dir = ensure_dir(stage_dir(outputs_root, "mesh"))
mask_dir = stage_dir(outputs_root, "mask")
_require(mask_dir / "meta.json", "mesh", args, "mask")
_require(mask_dir / "mask_frame0.npz", "mesh", args, "mask")
meta = _load_mask_meta(outputs_root)
if args.mesh_source in ("trellis", "sam3d"):
# Both externally-generated sources share the exact same contract here:
# a standalone script (scripts/trellis_generate.py or
# scripts/sam3d_generate.py), run beforehand in that generator's own
# conda env (trellis2 / sam3d-objects respectively -- see each
# script's module docstring), writes a textured/vertex-colored .glb;
# this stage only loads it. Generation is never shelled out to from
# here, matching how --mesh-source trellis has always worked.
#
# NOTE(sam3d): scripts/sam3d_generate.py was validated end-to-end
# against the sam-3d-objects repo's own sample image (2026-07-30; see
# logs/sam3d_setup/09_smoketest_generate.log and
# 11_smoketest_textured.log) and this loading branch was confirmed to
# accept its .glb output (identical trimesh contract to the trellis
# path). Not yet exercised with a real DROID episode crop through
# this actual CLI flag end-to-end -- only the sam3d_generate.py step
# itself and this loader were checked independently.
if args.glb is None:
raise DataError(
f"mesh stage: --mesh-source {args.mesh_source} requires --glb "
f"<path to {'TRELLIS' if args.mesh_source == 'trellis' else 'SAM 3D Objects'} output>"
)
if not args.glb.exists():
raise DataError(f"mesh stage: --glb path does not exist: {args.glb}")
# process=False: see _save_mesh's docstring -- welding vertices here
# would desync `uv` (per-vertex) from the geometry it was baked onto.
loaded = trimesh.load(str(args.glb), force="mesh", process=False)
vertex_colors, texture, uv = _visual_to_mesh_fields(loaded.visual)
mesh = ObjectMesh(
vertices=np.asarray(loaded.vertices, dtype=np.float64),
faces=np.asarray(loaded.faces, dtype=np.int64),
source=MeshSource.TRELLIS if args.mesh_source == "trellis" else MeshSource.SAM3D,
vertex_colors=vertex_colors,
texture=texture,
uv=uv,
metadata={"source_glb": str(args.glb)},
)
else:
mask_full = _load_mask_frame0(outputs_root)
video_w, video_h = meta["video_resolution"]
if mask_full.shape != (video_h, video_w):
raise DataError(
f"mesh stage: mask shape {mask_full.shape} != recorded video resolution "
f"{(video_h, video_w)}"
)
ctx = _load_clip_context(args.episode, args.camera, args.clip)
depth = _load_initial_depth(args.episode, args.clip, meta["camera_serial"])
adapter = _DepthRgbAdapter(depth, ctx.clip.initial_rgb)
points_world, _colors = object_point_cloud(
adapter, ctx.camera_annot, mask_full, (video_h, video_w)
)
builder = oriented_box_mesh if args.mesh_source == "proxy_box" else convex_hull_mesh
mesh = builder(points_world)
_save_mesh(mesh, out_dir)
stats = {
"source": mesh.source.value,
"n_vertices": int(mesh.vertices.shape[0]),
"n_faces": int(mesh.faces.shape[0]),
"extent_m": mesh.extent.tolist(),
}
(out_dir / "mesh_stats.json").write_text(json.dumps(stats, indent=2))
artifacts = StageArtifacts(stage="mesh", directory=out_dir, stats=stats)
artifacts.add("mesh_glb", out_dir / "mesh.glb")
artifacts.add("mesh_meta", out_dir / "mesh_meta.json")
artifacts.add("mesh_stats", out_dir / "mesh_stats.json")
return artifacts
# --------------------------------------------------------------------------- #
# Stage 3: align
# --------------------------------------------------------------------------- #
def _load_alignment(align_dir: Path) -> ObjectAlignment:
with np.load(align_dir / "alignment.npz") as npz:
transform, points_world, colors = npz["transform"], npz["points_world"], npz["colors"]
stats = json.loads((align_dir / "align_stats.json").read_text())
return ObjectAlignment(
transform=transform,
scale=stats["scale"],
points_world=points_world,
point_colors=colors,
rmse_m=stats["rmse_m"],
inlier_fraction=stats["inlier_fraction"],
frame_idx=0,
)
def run_align(args: argparse.Namespace, outputs_root: Path) -> StageArtifacts:
from fpgm.objects import align as align_module
from fpgm.objects.align import fit_mesh_to_points, object_point_cloud
out_dir = ensure_dir(stage_dir(outputs_root, "align"))
mask_dir = stage_dir(outputs_root, "mask")
mesh_dir = stage_dir(outputs_root, "mesh")
_require(mask_dir / "meta.json", "align", args, "mask")
_require(mesh_dir / "mesh.glb", "align", args, "mask,mesh")
meta = _load_mask_meta(outputs_root)
mask_full = _load_mask_frame0(outputs_root)
mesh = _load_mesh(mesh_dir)
ctx = _load_clip_context(args.episode, args.camera, args.clip)
depth = _load_initial_depth(args.episode, args.clip, meta["camera_serial"])
adapter = _DepthRgbAdapter(depth, ctx.clip.initial_rgb)
video_w, video_h = meta["video_resolution"]
points_world, colors = object_point_cloud(
adapter, ctx.camera_annot, mask_full, (video_h, video_w)
)
alignment = fit_mesh_to_points(
mesh, points_world, allow_scale=True, refine_icp=True,
rng=np.random.default_rng(args.seed),
)
alignment.frame_idx = 0
np.savez_compressed(
out_dir / "alignment.npz",
transform=alignment.transform, points_world=alignment.points_world, colors=colors,
)
stats = {
"scale": alignment.scale,
"rmse_m": alignment.rmse_m,
"inlier_fraction": alignment.inlier_fraction,
"n_points": int(points_world.shape[0]),
"position_m": alignment.position.tolist(),
}
(out_dir / "align_stats.json").write_text(json.dumps(stats, indent=2))
frame0_path = mask_dir / "frame0_bgr.png"
align_artifacts_files = {}
if frame0_path.exists():
frame0_bgr = cv2.imread(str(frame0_path))
camera_video = ctx.camera_annot.rescaled(video_w, video_h)
debug = align_module.save_debug(alignment, camera_video, frame0_bgr, out_dir, mesh=mesh)
align_artifacts_files.update(debug.files)
artifacts = StageArtifacts(
stage="align", directory=out_dir, stats=stats, files=align_artifacts_files
)
artifacts.add("alignment", out_dir / "alignment.npz")
artifacts.add("align_stats", out_dir / "align_stats.json")
return artifacts
# --------------------------------------------------------------------------- #
# Stage 4: act
# --------------------------------------------------------------------------- #
def _load_trajectory(act_dir: Path) -> ObjectTrajectory:
with np.load(act_dir / "trajectory.npz") as npz:
timestamps, transforms = npz["timestamps"], npz["transforms"]
gripper_distance = npz["gripper_distance_m"]
states = [InteractionState(s) for s in json.loads((act_dir / "states.json").read_text())]
stats = json.loads((act_dir / "act_stats.json").read_text())
return ObjectTrajectory(
timestamps=timestamps, transforms=transforms, states=states,
gripper_distance_m=gripper_distance, push_gain=stats["push_gain"],
)
def run_act(args: argparse.Namespace, outputs_root: Path) -> StageArtifacts:
from fpgm.objects.interaction import InteractionConfig, InteractionModel, summarize
from fpgm.robot.urdf import RobotModel
out_dir = ensure_dir(stage_dir(outputs_root, "act"))
align_dir = stage_dir(outputs_root, "align")
_require(align_dir / "alignment.npz", "act", args, "mask,mesh,align")
alignment = _load_alignment(align_dir)
traj_path = _load_trajectory_h5(args.episode)
ctx = _load_clip_context(args.episode, args.camera, args.clip)
with h5py.File(traj_path, "r") as f:
joint_positions_full = np.asarray(f["observation/robot_state/joint_positions"])
gripper_full = np.asarray(f["observation/robot_state/gripper_position"])
# clip.start/clip.n_frames are in the annotation clock; joint_positions_full is
# trajectory-clock, so the row indices go through ctx.timing's measured stride
# (see ClipTiming's docstring) instead of a plain [start, end) slice.
rows = _strided_rows(
ctx.clip.start,
ctx.timing.n_frames,
ctx.timing.annotation_stride,
joint_positions_full.shape[0],
"act stage",
)
if rows.size == 0:
raise DataError(
f"act stage: clip {args.clip} maps to trajectory rows starting at "
f"{ctx.clip.start * ctx.timing.annotation_stride}, which is already past "
f"the end of a {joint_positions_full.shape[0]}-step trajectory"
)
joint_positions = joint_positions_full[rows]
gripper = gripper_full[rows]
timestamps = ctx.timing.clip_frame_to_seconds(np.arange(rows.size, dtype=np.float64))
timestamps = timestamps - timestamps[0] # clip-local seconds, starting at 0
if not args.urdf.exists():
raise DataError(
f"act stage: URDF not found: {args.urdf}. Fetch it with "
"`python scripts/fetch_robot_description.py --source pointworld`."
)
robot = RobotModel(str(args.urdf), load_meshes=False)
cfg = InteractionConfig(push_gain=args.push_gain)
trajectory = InteractionModel(robot, cfg).solve(alignment, joint_positions, gripper, timestamps)
np.savez_compressed(
out_dir / "trajectory.npz",
timestamps=trajectory.timestamps, transforms=trajectory.transforms,
gripper_distance_m=trajectory.gripper_distance_m,
)
(out_dir / "states.json").write_text(json.dumps([s.value for s in trajectory.states]))
summary_text = summarize(trajectory)
(out_dir / "summary.txt").write_text(summary_text)
logger.info("interaction timeline:\n%s", summary_text)
stats = {
"push_gain": trajectory.push_gain,
"n_frames": len(trajectory),
"state_spans": [
{"state": s.value, "start": a, "end": b} for s, a, b in trajectory.state_spans()
],
}
(out_dir / "act_stats.json").write_text(json.dumps(stats, indent=2))
artifacts = StageArtifacts(stage="act", directory=out_dir, stats=stats)
artifacts.add("trajectory", out_dir / "trajectory.npz")
artifacts.add("states", out_dir / "states.json")
artifacts.add("summary_txt", out_dir / "summary.txt")
artifacts.add("act_stats", out_dir / "act_stats.json")
return artifacts
# --------------------------------------------------------------------------- #
# Stage 5: render
# --------------------------------------------------------------------------- #
def _object_speeds(trajectory: ObjectTrajectory) -> np.ndarray:
"""Per-frame |velocity| (m/s) of the object's own trajectory, central differences."""
n = len(trajectory)
if n < 2:
return np.zeros(n)
positions = trajectory.positions
dt = np.gradient(trajectory.timestamps)
dt = np.where(dt == 0, np.nan, dt)
velocity = np.gradient(positions, axis=0) / dt[:, None]
with np.errstate(invalid="ignore"):
return np.nan_to_num(np.linalg.norm(velocity, axis=1))
def run_render(args: argparse.Namespace, outputs_root: Path) -> StageArtifacts:
from fpgm.objects.scene import (
SceneRenderer,
draw_point_cloud,
draw_scene_hud,
free_camera,
project_point_cloud,
)
from fpgm.robot.overlay import composite
from fpgm.robot.urdf import RobotModel
mask_dir = stage_dir(outputs_root, "mask")
mesh_dir = stage_dir(outputs_root, "mesh")
align_dir = stage_dir(outputs_root, "align")
act_dir = stage_dir(outputs_root, "act")
out_dir = ensure_dir(stage_dir(outputs_root, "render"))
_require(mask_dir / "meta.json", "render", args, "mask")
_require(mesh_dir / "mesh.glb", "render", args, "mask,mesh")
_require(align_dir / "alignment.npz", "render", args, "mask,mesh,align")
_require(act_dir / "trajectory.npz", "render", args, "mask,mesh,align,act")
meta = _load_mask_meta(outputs_root)
mesh = _load_mesh(mesh_dir)
alignment = _load_alignment(align_dir)
trajectory = _load_trajectory(act_dir)
n = len(trajectory)
if n == 0:
raise DataError("render stage: act stage produced an empty trajectory")
if not args.urdf.exists():
raise DataError(
f"render stage: URDF not found: {args.urdf}. Fetch it with "
"`python scripts/fetch_robot_description.py --source pointworld`."
)
robot = RobotModel(str(args.urdf), load_meshes=True)
ctx = _load_clip_context(args.episode, args.camera, args.clip)
video_w, video_h = meta["video_resolution"]
camera_video = ctx.camera_annot.rescaled(video_w, video_h)
traj_path = _load_trajectory_h5(args.episode)
with h5py.File(traj_path, "r") as f:
joint_positions_full = np.asarray(f["observation/robot_state/joint_positions"])
gripper_full = np.asarray(f["observation/robot_state/gripper_position"])
extrinsics_key = f"observation/camera_extrinsics/{meta['camera_serial']}_left"
if extrinsics_key not in f:
raise DataError(f"render stage: trajectory.h5 has no dataset {extrinsics_key!r}")
extrinsics_6d_full = np.asarray(f[extrinsics_key])
# `n` frames of the *annotation* clock (act stage's own trajectory length, which
# may already be shorter than the clip if act stage itself truncated -- see
# _strided_rows there) map onto trajectory.h5/mp4 rows via ctx.timing's measured
# stride, not a plain [start, start+n) slice.
stride = ctx.timing.annotation_stride
joint_rows = _strided_rows(
ctx.clip.start, n, stride, joint_positions_full.shape[0], "render stage (joint_positions)"
)
extrinsics_rows = _strided_rows(
ctx.clip.start, n, stride, extrinsics_6d_full.shape[0], "render stage (extrinsics)"
)
if joint_rows.size != n or extrinsics_rows.size != n:
raise DataError(
f"render stage: trajectory.h5 does not have enough rows to cover all {n} of "
f"act's frames for clip {args.clip} (mapped rows start at "
f"{ctx.clip.start * stride}, stride={stride}); re-run act on a shorter clip"
)
joint_positions = joint_positions_full[joint_rows]
gripper = gripper_full[joint_rows]
extrinsics_6d = extrinsics_6d_full[extrinsics_rows]
mp4_fps, _count, _size = read_mp4_properties(ctx.mp4_path)
fps = mp4_fps or _TRAJECTORY_FPS
target = trajectory.positions.mean(axis=0)
obj_span = float(np.linalg.norm(mesh.vertices.max(axis=0) - mesh.vertices.min(axis=0)))
distance = args.freecam_distance or max(0.5, 3.0 * max(obj_span, 0.05))
speeds = _object_speeds(trajectory)
point_colors = (
alignment.point_colors
if alignment.point_colors is not None
else np.zeros_like(alignment.points_world, dtype=np.uint8)
)
scene_renderer = SceneRenderer(robot.visual_meshes(), mesh, video_w, video_h)
object_coverage: list[float] = []
robot_coverage: list[float] = []
video_start = int(ctx.timing.clip_frame_to_video_frame(0))
video_end = int(ctx.timing.clip_frame_to_video_frame(n))
try:
with ClipFrameSource(str(ctx.mp4_path), video_start, video_end, stride=stride) as frames, \
VideoWriter(out_dir / "scene_overlay.mp4", fps=fps) as overlay_writer, \
VideoWriter(out_dir / "scene_freecam.mp4", fps=fps) as freecam_writer:
if frames.n_frames < n:
raise DataError(
f"render stage: video only yielded {frames.n_frames}/{n} frames for "
f"the mapped range [{video_start}, {video_end}) stride={stride}"
)
for t in range(n):
link_poses = robot.link_poses(joint_positions[t], float(gripper[t]))
object_pose = trajectory.transforms[t]
state = trajectory.states[t]
timestamp = float(trajectory.timestamps[t])
world_to_cam = cam2world_vector_to_world2cam(extrinsics_6d[t])
camera_t = Camera(camera_video.K, world_to_cam)
result = scene_renderer.render(link_poses, object_pose, camera_t)
object_coverage.append(float(result.object_mask.mean()))
robot_coverage.append(float(result.robot_mask.mean()))
frame_bgr = frames.read(t)
composed = composite(frame_bgr, result, alpha=0.85)
uv, cols = project_point_cloud(
alignment.points_world, point_colors, camera_t, (video_h, video_w)
)
composed = draw_point_cloud(composed, uv, cols, radius=1)
composed = draw_scene_hud(
composed, t, timestamp, state, float(speeds[t]), trajectory.push_gain
)
overlay_writer.write(composed)
azimuth = 360.0 * t / max(n, 1)
free_cam = free_camera(
target, distance, azimuth, args.freecam_elevation, camera_video.K
)
free_result = scene_renderer.render(link_poses, object_pose, free_cam)
canvas = np.full((video_h, video_w, 3), _FREECAM_BG, dtype=np.uint8)
canvas = composite(canvas, free_result, alpha=1.0)
uv_f, cols_f = project_point_cloud(
alignment.points_world, point_colors, free_cam, (video_h, video_w)
)
canvas = draw_point_cloud(canvas, uv_f, cols_f, radius=1)
canvas = draw_scene_hud(
canvas, t, timestamp, state, float(speeds[t]), trajectory.push_gain
)
freecam_writer.write(canvas)
finally:
scene_renderer.close()
stats = {
"n_frames": n,
"fps": fps,
"mean_object_mask_coverage": float(np.mean(object_coverage)) if object_coverage else 0.0,
"mean_robot_mask_coverage": float(np.mean(robot_coverage)) if robot_coverage else 0.0,
"peak_object_speed_mps": float(np.max(speeds)) if len(speeds) else 0.0,
"freecam_distance_m": distance,
}
(out_dir / "render_stats.json").write_text(json.dumps(stats, indent=2))
artifacts = StageArtifacts(stage="render", directory=out_dir, stats=stats)
artifacts.add("scene_overlay", out_dir / "scene_overlay.mp4")
artifacts.add("scene_freecam", out_dir / "scene_freecam.mp4")
artifacts.add("render_stats", out_dir / "render_stats.json")
return artifacts
# --------------------------------------------------------------------------- #
# Summary + main
# --------------------------------------------------------------------------- #
_RUNNERS = {
"mask": run_mask,
"mesh": run_mesh,
"align": run_align,
"act": run_act,
"render": run_render,
}
def _write_summary(outputs_root: Path, args: argparse.Namespace) -> dict:
"""Merge whatever stage artefacts currently exist on disk into one summary.
Deliberately reads from disk (not from this run's in-memory results), so
the summary is always the full pipeline's state even when this invocation
only ran a subset of stages.
"""
objects_dir = ensure_dir(outputs_root / "objects")
summary: dict = {"episode": args.episode, "camera": args.camera, "clip": args.clip}
mask_meta = stage_dir(outputs_root, "mask") / "meta.json"
if mask_meta.exists():
summary["mask"] = json.loads(mask_meta.read_text())
mesh_stats = stage_dir(outputs_root, "mesh") / "mesh_stats.json"
if mesh_stats.exists():
summary["mesh"] = json.loads(mesh_stats.read_text())
align_stats = stage_dir(outputs_root, "align") / "align_stats.json"
if align_stats.exists():
summary["align"] = json.loads(align_stats.read_text())
act_dir = stage_dir(outputs_root, "act")
act_stats = act_dir / "act_stats.json"
if act_stats.exists():
summary["act"] = json.loads(act_stats.read_text())
summary_txt = act_dir / "summary.txt"
if summary_txt.exists():
summary["act"]["timeline"] = summary_txt.read_text()
render_stats = stage_dir(outputs_root, "render") / "render_stats.json"
if render_stats.exists():
summary["render"] = json.loads(render_stats.read_text())
path = objects_dir / "summary.json"
path.write_text(json.dumps(summary, indent=2))
logger.info("summary written to %s", path)
return summary
def main() -> int:
args = parse_args()
setup_logging()
stages = [s.strip() for s in args.stages.split(",") if s.strip()]
unknown = [s for s in stages if s not in STAGES]
if unknown:
raise DataError(f"unknown stage(s) {unknown}; choose from {STAGES}")
outputs_root = args.outputs or (REPO_ROOT / "outputs" / args.episode)
ensure_dir(outputs_root)
logger.info(
"episode=%s camera=%s clip=%s stages=%s", args.episode, args.camera, args.clip, stages
)
for stage in stages:
logger.info("=== stage %d/%d: %s ===", STAGE_INDEX[stage], len(STAGES), stage)
artifacts = _RUNNERS[stage](args, outputs_root)
logger.info("%s stage done -> %s", stage, artifacts.directory)
summary = _write_summary(outputs_root, args)
print("\n" + "=" * 78)
print(f"episode: {args.episode}")
print(f"camera: {args.camera} clip: {args.clip}")
print(f"stages: {stages}")
print(f"outputs: {outputs_root / 'objects'}")
if "render" in summary:
print(json.dumps(summary["render"], indent=2))
print("=" * 78)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
45.2 kB
·
Xet hash:
afc6ce3c686ef84c3f19c419d9b1029edf958ea6488302a6ea7dbac95ddfcec5

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