twanghcmut/backup-foundation-physics / scripts /find_placement_surface.py
twanghcmut's picture
download
raw
20 kB
#!/usr/bin/env python
"""Fit the top surface of a masked object (the book stack) as a robot placement target.
This is deliberately its own script rather than a sixth `run_object_pipeline.py`
stage: none of stages 2-5 (mesh / align / act / render) apply here -- there is
no need to fit a mesh or animate a trajectory for a static stack of books we
only want a flat place-target on top of. All that is needed is stage 1's mask
plus the depth-derived point cloud stage 3 already knows how to build
(`fpgm.objects.align.object_point_cloud`), and a plane fit on top of it.
Method:
1. `object_point_cloud` turns the book-stack mask + frame-0 depth into a
world-frame (robot-base, `panda_link0`) point cloud, with the same
robust MAD depth-outlier rejection stage 3 relies on -- a handful of
leaked background pixels would otherwise drag the fit metres away (see
that function's own docstring for the measured 0.39 m example).
2. The book stack is not flat -- it is several books of different
thickness with a near-vertical front/page edge below the top cover, so
a plane fit over the *whole* cloud averages the stair-stepped sides
into a tilted mess. Only a *thin* band at the very top of the cloud's
world-Z range is the top book's actual flat cover; empirically on this
scene that band is only ~8% of the full stack height (see
`--top-band-frac`'s docstring) -- looser than that and the band starts
including the front edge, which has more points than the true top face
at this camera's grazing angle and pulls a plain fit off-horizontal
(this was observed directly while building this script; see the
validation-stance note below).
3. RANSAC first, gated to near-horizontal candidates only (see
`--min-normal-up`) since a placement target must be a surface an
object can rest on under gravity, not merely whatever plane the data
supports most -- then one SVD least-squares refit on the RANSAC inlier
set, which is the actual best-fit plane through the points RANSAC
identified as "the top face" rather than through an arbitrary 3-point
sample.
Validation stance (read before trusting the output):
A prior mesh-fitting bug in this project reported rmse=1.96cm,
inlier_fraction=99% for a mesh that was 14x too large in volume, because
point-to-surface RMSE is a ONE-SIDED metric -- an oversized surface that
encloses the observed points scores just as well as a correctly-sized one.
The same failure mode applies here: a plane can have a tiny RMS residual
while its RANSAC inlier set is a tiny, unrepresentative patch of the real
top face. So this script always reports `surface_extent_m` (the fitted
plane's own inlier footprint) *and* `cloud_extent_m` /
`cloud_footprint_uv_m` (the full observed cloud's extent, the second one
projected through the identical plane basis so the comparison is
apples-to-apples) side by side, and logs a loud warning if the plane's
footprint covers under ~30% of what the full cloud's footprint suggests is
there. A human should also look at the debug overlay PNG -- these numbers
alone cannot distinguish "SAM correctly masked a small book cover" from
"the plane fit collapsed onto a corner".
Usage:
PYTHONPATH=src python scripts/find_placement_surface.py \\
--episode <uuid> --camera ext1 --clip 0:11 \\
--mask-outputs outputs/<uuid>/objects_books/0_11 \\
--out outputs/<uuid>/objects_books/0_11/placement
"""
from __future__ import annotations
import argparse
import importlib.util
import json
import sys
from pathlib import Path
import cv2
import numpy as np
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
from fpgm.objects.align import object_point_cloud # noqa: E402
from fpgm.objects.scene import draw_point_cloud, project_point_cloud # noqa: E402
from fpgm.utils.io import ensure_dir # noqa: E402
from fpgm.utils.logging import get_logger, setup_logging # noqa: E402
# Reuse run_object_pipeline.py's private clip-context/metadata/depth loaders
# instead of duplicating them -- same trick as scripts/mask_object_manual.py
# and scripts/measure_push_tracked.py. The sys.modules registration before
# exec_module is required: run_object_pipeline.py's ClipContext is a
# @dataclass, and dataclass decorators look the defining module up in
# sys.modules while they run, which fails if the module isn't registered yet.
_spec = importlib.util.spec_from_file_location(
"run_object_pipeline", REPO_ROOT / "scripts" / "run_object_pipeline.py"
)
rop = importlib.util.module_from_spec(_spec)
sys.modules[_spec.name] = rop
_spec.loader.exec_module(rop)
logger = get_logger("find_placement_surface")
#: Robot-base (panda_link0) convention used throughout this pipeline: +Z is up.
#: See fpgm/objects/scene.py's module-level constant for the same assumption.
_WORLD_UP = np.array([0.0, 0.0, 1.0])
#: Below this, a plane footprint is flagged as suspiciously small relative to
#: the full observed cloud's footprint through the same basis -- see the
#: module docstring's validation-stance note.
_FOOTPRINT_RATIO_WARN = 0.30
def parse_args() -> argparse.Namespace:
p = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
p.add_argument("--episode", required=True)
p.add_argument("--camera", default="ext1", choices=["ext1", "ext2"])
p.add_argument("--clip", default="0:11")
p.add_argument("--mask-outputs", type=Path, required=True,
help="the --outputs directory previously passed to mask_object_manual.py")
p.add_argument("--top-band-frac", type=float, default=0.08,
help="fraction of the cloud's world-Z range, from the top, treated as "
"'the top surface' before plane fitting (see module docstring). Tuned "
"empirically on the book-stack scene this script was built for: a stack's "
"actual flat top cover is a thin sliver at the very top (~5-10%% of the full "
"stack height here); anything looser (measured up to ~0.30 on this scene) "
"starts pulling in the topmost book's near-vertical front/page edge, which "
"has *more* points than the true top face at this camera's grazing angle and "
"so drags a plain RANSAC fit off-horizontal. Re-tune per scene with "
"--min-normal-up as the tell: if the reported normal isn't close to vertical, "
"shrink this until it is (see that flag's own docstring)")
p.add_argument("--ransac-iters", type=int, default=2000)
p.add_argument("--ransac-thresh-m", type=float, default=0.006,
help="inlier distance-to-plane threshold, metres; ~ the depth noise floor "
"for a workbench-distance RGBD capture, not a tuned-for-this-object value")
p.add_argument("--min-normal-up", type=float, default=0.7,
help="minimum |normal . world_up| for a candidate plane to be considered at "
"all -- a placement TARGET must be a surface an object can rest on under "
"gravity (near-horizontal), and without this a plain max-inliers RANSAC can "
"(and, on this scene, does) lock onto a larger near-vertical face -- e.g. a "
"book's front cover/page-edge -- instead of its horizontal top, because that "
"face happens to carry more points at this camera's grazing viewing angle on "
"the true top. 0.7 ~= within 45 degrees of vertical")
p.add_argument("--seed", type=int, default=0)
p.add_argument("--out", type=Path, default=None,
help="output directory (default: <mask-outputs>/objects/2_placement)")
return p.parse_args()
def _svd_plane(points: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]:
"""Least-squares plane through `points`: (normal, centroid, in-plane basis).
The centroid of the input points always lies exactly on the returned
plane -- it is the point the plane is fit *through* -- so callers that want
"a point on the plane" never need a separate projection step for the
centroid specifically.
`basis` is `(2, 3)`: the two largest right-singular vectors of the
centered points, i.e. the in-plane directions of greatest spread. Used
both as the projection axes for footprint/extent measurements and to keep
that footprint's orientation meaningful (aligned with the surface's own
long/short axes) rather than arbitrary world XY.
"""
centroid = points.mean(axis=0)
centered = points - centroid
_, _, vt = np.linalg.svd(centered, full_matrices=False)
normal = vt[-1]
basis = vt[:2]
return normal, centroid, basis
def _fit_top_plane(
band_points: np.ndarray,
thresh_m: float,
n_iters: int,
rng: np.random.Generator,
min_normal_up: float,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, np.ndarray, float]:
"""RANSAC-then-refit plane fit. Returns (normal, centroid, basis, inlier_mask, rms_m).
RANSAC only ever needs to decide *which points are inliers* -- the 3-point
candidate plane's own accuracy does not matter, since the final normal and
centroid always come from an SVD refit over whichever inlier set won (see
below). This matters because a 3-point plane is exactly as noise-sensitive
as the single depth pixels it is built from; the refit is what actually
uses the redundancy in the ~hundreds of top-band points to average that
noise out.
`min_normal_up` rejects candidate 3-point planes that are not near-horizontal
*before* they are scored by inlier count. Plain max-inlier RANSAC picks
whichever plane the data supports most, with no notion of "supports an
object resting on it" -- on this script's own first (unconstrained) run
against the book-stack scene, that picked a near-vertical face (front
cover/page-edge) over the true horizontal top cover, because the vertical
face happened to carry more consistent points at this camera's grazing
viewing angle on the actual top. The gate encodes the one prior that
actually matters for a placement target -- gravity -- directly, instead of
hoping "top band by Z" alone disambiguates it.
"""
n = band_points.shape[0]
if n < 3:
raise ValueError(f"_fit_top_plane: need >=3 points in the top band, got {n}")
best_inliers: np.ndarray | None = None
best_count = -1
n_tried = 0
for _ in range(n_iters):
idx = rng.choice(n, size=3, replace=False)
p0, p1, p2 = band_points[idx]
normal = np.cross(p1 - p0, p2 - p0)
norm = np.linalg.norm(normal)
if norm < 1e-12:
continue # degenerate (near-collinear) sample; skip rather than divide by ~0
normal = normal / norm
if abs(float(np.dot(normal, _WORLD_UP))) < min_normal_up:
continue # not near-horizontal; not a candidate resting surface, see docstring
n_tried += 1
d = -normal @ p0
dist = band_points @ normal + d
inliers = np.abs(dist) <= thresh_m
count = int(inliers.sum())
if count > best_count:
best_count, best_inliers = count, inliers
if best_inliers is None or best_count < 3:
raise ValueError(
f"_fit_top_plane: found no near-horizontal (|normal.up| >= {min_normal_up}) 3-point "
f"plane candidate with >=3 inliers out of {n_iters} samples ({n_tried} passed the "
"horizontality gate) -- the top band likely does not contain a real resting surface; "
"try a different --top-band-frac or inspect the mask"
)
# One SVD refit through the winning inlier set, then one more inlier pass
# against *that* refined plane (a handful of points near the threshold
# boundary can flip in/out once the plane itself is no longer a noisy
# 3-point guess) before the final refit that is actually reported.
normal, centroid, basis = _svd_plane(band_points[best_inliers])
dist = (band_points - centroid) @ normal
inliers2 = np.abs(dist) <= thresh_m
normal, centroid, basis = _svd_plane(band_points[inliers2])
dist_final = (band_points - centroid) @ normal
rms = float(np.sqrt(np.mean(dist_final[inliers2] ** 2)))
if abs(float(np.dot(normal, _WORLD_UP))) < min_normal_up:
raise ValueError(
f"_fit_top_plane: refit through the winning inlier set drifted back to a non-horizontal "
f"normal {normal.round(3).tolist()} (|.up|={abs(float(np.dot(normal, _WORLD_UP))):.3f} < "
f"{min_normal_up}) -- the inlier set the horizontality gate accepted was not actually "
"coherent; do not trust this fit"
)
return normal, centroid, basis, inliers2, rms
def _in_plane_extent(points: np.ndarray, centroid: np.ndarray, basis: np.ndarray) -> np.ndarray:
"""`(2,)` [u_extent, v_extent]: `points`' spread along the plane's own basis axes."""
projected = (points - centroid) @ basis.T
return projected.max(axis=0) - projected.min(axis=0)
def main() -> int:
args = parse_args()
setup_logging()
mask_dir = rop.stage_dir(args.mask_outputs, "mask")
meta = json.loads((mask_dir / "meta.json").read_text())
with np.load(mask_dir / "mask_frame0.npz") as npz:
mask_full = npz["mask"].astype(bool)
video_w, video_h = meta["video_resolution"]
logger.info("loaded mask: prompt=%r, %d px on, video_resolution=%dx%d",
meta["prompt"], int(mask_full.sum()), video_w, video_h)
out_dir = ensure_dir(args.out) if args.out is not None else ensure_dir(
Path(args.mask_outputs) / "objects" / "2_placement"
)
ctx = rop._load_clip_context(args.episode, args.camera, args.clip)
depth = rop._load_initial_depth(args.episode, args.clip, meta["camera_serial"])
adapter = rop._DepthRgbAdapter(depth, ctx.clip.initial_rgb)
points_world, colors = object_point_cloud(adapter, ctx.camera_annot, mask_full, (video_h, video_w))
n_cloud = points_world.shape[0]
cloud_centroid = points_world.mean(axis=0)
cloud_extent = points_world.max(axis=0) - points_world.min(axis=0)
logger.info(
"object cloud: %d points, extent=%s m, centroid=%s m",
n_cloud, np.round(cloud_extent, 4).tolist(), np.round(cloud_centroid, 4).tolist(),
)
z = points_world[:, 2]
z_min, z_max = float(z.min()), float(z.max())
z_threshold = z_max - args.top_band_frac * (z_max - z_min)
band_mask = z >= z_threshold
band_points = points_world[band_mask]
logger.info(
"top band: z in [%.4f, %.4f] m (top %.0f%% of full z-range [%.4f, %.4f]) -> %d/%d points",
z_threshold, z_max, args.top_band_frac * 100, z_min, z_max, int(band_mask.sum()), n_cloud,
)
rng = np.random.default_rng(args.seed)
normal, position, basis, inlier_mask_band, rms_m = _fit_top_plane(
band_points, args.ransac_thresh_m, args.ransac_iters, rng, args.min_normal_up
)
n_inliers = int(inlier_mask_band.sum())
inlier_points = band_points[inlier_mask_band]
# PCA/SVD gives the normal's axis but not its sign; "up" in this pipeline's
# world frame is a fixed, known convention (+Z, panda_link0), not something
# to infer from the data, so it is enforced directly rather than left to
# whichever of the two SVD sign conventions happened to come out.
if np.dot(normal, _WORLD_UP) < 0.0:
normal = -normal
basis = np.array([basis[0], -basis[1]]) # keep basis right-handed with the flipped normal
surface_extent = _in_plane_extent(inlier_points, position, basis)
cloud_footprint_uv = _in_plane_extent(points_world, position, basis)
ratios = surface_extent / np.maximum(cloud_footprint_uv, 1e-9)
extent_flag = bool(np.any(ratios < _FOOTPRINT_RATIO_WARN))
logger.info(
"plane fit: n_inliers=%d/%d, rms=%.4f m, normal=%s, position=%s",
n_inliers, band_points.shape[0], rms_m, np.round(normal, 4).tolist(), np.round(position, 4).tolist(),
)
logger.info(
"extent check: surface_extent_m(in-plane)=%s vs cloud_footprint_uv_m(same basis)=%s "
"(ratios=%s)",
np.round(surface_extent, 4).tolist(), np.round(cloud_footprint_uv, 4).tolist(),
np.round(ratios, 3).tolist(),
)
if extent_flag:
logger.warning(
"!! plane footprint covers only %.0f%% of the full cloud's footprint along at least "
"one axis (threshold %.0f%%) -- this is exactly the one-sided-RMSE failure mode this "
"script was told to guard against (see module docstring); inspect the debug overlay "
"before trusting this placement target.",
100.0 * float(ratios.min()), _FOOTPRINT_RATIO_WARN * 100.0,
)
result = {
"episode": args.episode,
"camera": args.camera,
"clip": args.clip,
"prompt": meta["prompt"],
"position_m": position.tolist(),
"normal": normal.tolist(),
"plane_rms_m": rms_m,
"n_inliers": n_inliers,
"n_band_points": int(band_points.shape[0]),
"n_cloud_points": n_cloud,
"surface_extent_m": surface_extent.tolist(),
"cloud_extent_m": cloud_extent.tolist(),
"cloud_footprint_uv_m": cloud_footprint_uv.tolist(),
"cloud_centroid_m": cloud_centroid.tolist(),
"z_band_used": [z_threshold, z_max],
"footprint_ratio_min": float(ratios.min()),
"footprint_ratio_flagged": extent_flag,
"ransac_thresh_m": args.ransac_thresh_m,
"top_band_frac": args.top_band_frac,
}
out_path = out_dir / "placement_target.json"
out_path.write_text(json.dumps(result, indent=2))
logger.info("wrote %s", out_path)
# --- debug overlay: project the full cloud (context), the plane inliers
# (what the fit actually used), and the placement point back onto frame 0.
frame0_bgr = cv2.imread(str(mask_dir / "frame0_bgr.png"))
if frame0_bgr is None:
raise FileNotFoundError(f"could not read {mask_dir / 'frame0_bgr.png'}")
camera_video = ctx.camera_annot.rescaled(video_w, video_h)
overlay = frame0_bgr.copy()
uv_cloud, colors_cloud = project_point_cloud(points_world, colors, camera_video, (video_h, video_w))
overlay = draw_point_cloud(overlay, uv_cloud, colors_cloud, radius=1)
green = np.tile(np.array([[60, 220, 60]], dtype=np.uint8), (inlier_points.shape[0], 1))
uv_inliers, colors_inliers = project_point_cloud(inlier_points, green, camera_video, (video_h, video_w))
overlay = draw_point_cloud(overlay, uv_inliers, colors_inliers, radius=2)
uv_pos, depth_pos = camera_video.project(position[None, :])
if depth_pos[0] > 0:
center = tuple(np.round(uv_pos[0]).astype(int))
cv2.drawMarker(overlay, center, (0, 0, 255), markerType=cv2.MARKER_CROSS,
markerSize=24, thickness=2, line_type=cv2.LINE_AA)
cv2.circle(overlay, center, 10, (0, 0, 255), 2, lineType=cv2.LINE_AA)
# A short segment along the outward normal, so the overlay also shows
# whether "up" came out pointing the right way, not just "somewhere".
tip_world = position + 0.05 * normal
uv_tip, depth_tip = camera_video.project(tip_world[None, :])
if depth_tip[0] > 0:
cv2.line(overlay, center, tuple(np.round(uv_tip[0]).astype(int)),
(0, 0, 255), 2, lineType=cv2.LINE_AA)
overlay_path = out_dir / "placement_overlay.png"
cv2.imwrite(str(overlay_path), overlay)
logger.info("wrote %s", overlay_path)
print(json.dumps(result, indent=2))
return 0
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
20 kB
·
Xet hash:
21c6d1c2135993cbdeaad1240fd49e9bfd1cdbc1eb9d65e655b0bf28aa7b0246

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