twanghcmut's picture
download
raw
24.1 kB
#!/usr/bin/env python
"""Stage 2: (control, target) appearance-training pairs for the VACE fine-tune.
Same output shape as the zero-shot probe (``render_robot_over_moge.py``): control
= the real frame with a URDF-rendered Franka composited over the real arm, target
= the untouched real frame. That pair is exactly the appearance mapping the
fine-tune has to learn -- "this white/black CAD render is that real robot" -- and
it is the mapping VACE-1.3B provably does not have zero-shot: given a control
whose robot was already a clean, correctly-posed white Franka + black Robotiq, it
still drew a yellow toy arm from about frame 50.
**No object meshes, no SAM3, no quality gates.** The datagen pipeline's S2/S3/S6/S7
gates exist to certify object *pose and geometry*; appearance training needs
neither, so nothing here can reject an episode and there is no pass rate to lose.
**No PointWorld.** Measured, not assumed:
* the frame->trajectory-row map is ``n_video_frames / n_trajectory_rows``
(``ClipTiming.from_counts``); the flows reader was only ever used to enumerate
clips, which this script does not need;
* extrinsics come from ``cameras.json`` (172 MB for 42935 episodes, already on
disk) with ``metadata.json`` as fallback;
* intrinsics are a property of the physical ZED unit, not the episode -- across
62 episodes of serial 22008760 the focal length varies by 0.011%, and all of
DROID uses only 33 distinct exterior cameras -- so a serial->intrinsic table
harvested once covers every episode those cameras ever recorded.
That drops the 2.28 TB flows dependency to ~0.
**Everything runs at the training resolution, and both videos are written there.**
Measured at 832x480 against 1280x720: render 56.3 vs 43.4 fps, composite 159 vs
46 fps, encode 141 vs 72 fps. Writing the target out too (rather than symlinking
the source mp4 and cropping at train time) costs one extra H.264 generation on
top of DROID's own, and buys: a self-contained training set at 2.8 MB/clip
instead of 17.9, a dataloader that does zero resizing, and no runtime dependency
on ``data/droid_raw``. The trade is that changing training resolution means
re-running this stage -- deliberate, since VACE-1.3B trains at one resolution.
**Occlusion tolerance.** The robot wins where its render is in front of the
MoGe surface by more than ``--depth-tol-m``. The default 0.15 m is measured, not
picked: on a clip with no true occlusion at all, a 0.02 m margin still rejects
18.8% of robot pixels, 0.05 m rejects 13.4%, 0.10 m rejects 4.0% and 0.20 m
rejects 0.078% -- a tight margin is not detecting occlusion, it is punching noise
holes in the arm. Across the first 126-clip run 0.15 m rejected 0.20% (median).
Env: ``fpgm``, EGL (56.3 fps at 832x480, against 2.7 fps on the OSMesa default).
Usage:
PYOPENGL_PLATFORM=egl PYTHONPATH=src \\
/home/quang/miniconda3/envs/fpgm/bin/python -u scripts/appearance_control.py \\
--work <scratch> --out outputs/appearance_pairs --follow
"""
from __future__ import annotations
import argparse
import json
import os
import shutil
import subprocess
import sys
import time
from pathlib import Path
import cv2
import h5py
import numpy as np
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
sys.path.insert(0, str(REPO_ROOT / "scripts"))
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
from appearance_depth import cover_crop, cover_crop_params # noqa: E402
_FFMPEG = Path("/home/quang/miniconda3/envs/ffmpeg_libs/bin/ffmpeg")
_ERODE = np.ones((9, 9), np.uint8)
_TRAJECTORY_FPS = 15.0
def ffmpeg() -> str:
return str(_FFMPEG) if _FFMPEG.exists() else (shutil.which("ffmpeg") or "ffmpeg")
class H264Writer:
"""Streaming H.264 writer. Frames are pushed in; ffmpeg runs concurrently.
``crf 18`` at 832x480 measured 9.4 KB/frame against 18 KB at crf 14, for
output that is still above the quality of the DROID source it came from
(already H.264-compressed, then downscaled here, which removes the
high-frequency content a low crf would have been spending bits on).
Encoding at 141 fps it is not the bottleneck, so ``preset medium`` is kept
rather than trading quality for speed that nothing would use.
"""
def __init__(self, out: Path, w: int, h: int, fps: float, crf: int) -> None:
out.parent.mkdir(parents=True, exist_ok=True)
self.proc = subprocess.Popen(
[ffmpeg(), "-y", "-hide_banner", "-loglevel", "error", "-f", "rawvideo",
"-pix_fmt", "bgr24", "-s", f"{w}x{h}", "-r", f"{fps:.4f}", "-i", "pipe:0",
"-c:v", "libx264", "-preset", "medium", "-pix_fmt", "yuv420p",
"-crf", str(crf), str(out)],
stdin=subprocess.PIPE)
self.out = out
def write(self, frame_bgr: np.ndarray) -> None:
self.proc.stdin.write(np.ascontiguousarray(frame_bgr).tobytes())
def close(self) -> None:
self.proc.stdin.close()
if self.proc.wait() != 0:
raise RuntimeError(f"ffmpeg failed writing {self.out}")
def rel(p: Path) -> str:
"""Repo-relative string, whichever way the caller spelled the path."""
p = Path(p).resolve()
try:
return str(p.relative_to(REPO_ROOT))
except ValueError:
return str(p)
def load_intrinsic_table(path: Path) -> dict:
return json.loads(path.read_text())
def intrinsics_for(table: dict, serial: str, w: int, h: int):
"""Real calibration for ``serial`` at the *source* ``w x h``. ``(intr, source)``.
Falls back to the table's ``_default`` (the median ZED unit) for a serial that
was never harvested. The fallback is recorded in the clip's meta so an audit
can find every clip built on a guessed principal point -- across the 12 known
units cy/h spans 0.478-0.518, which is 29 px at 720, so the guess is not free.
"""
from fpgm.types import CameraIntrinsics
entry = table["cameras"].get(serial)
source = f"table:{serial}"
if entry is None:
entry, source = table["_default"], "default"
return CameraIntrinsics(
fx=entry["fx_over_w"] * w, fy=entry["fy_over_h"] * h,
cx=entry["cx_over_w"] * w, cy=entry["cy_over_h"] * h,
width=w, height=h,
), source
def crop_intrinsics(intr, out_w: int, out_h: int):
"""Rescale + crop-shift ``intr`` so it stays valid for :func:`cover_crop`'s output.
Mirrors ``_CoverCrop.apply_intrinsics``. Without this the render would be
projected with the uncropped camera and land in the wrong place -- the crop
is not a centred one (832/1280 != 480/720), so it shifts cx by ~10 px.
"""
from fpgm.types import CameraIntrinsics
rw, rh, ox, oy = cover_crop_params(intr.width, intr.height, out_w, out_h)
sx, sy = rw / intr.width, rh / intr.height
return CameraIntrinsics(fx=intr.fx * sx, fy=intr.fy * sy,
cx=intr.cx * sx - ox, cy=intr.cy * sy - oy,
width=out_w, height=out_h)
def robust_scale(src: np.ndarray, dst: np.ndarray) -> tuple[float, float]:
"""Median of ``dst/src`` -- SCALE ONLY, no shift. Returns ``(a, residual_m)``.
MoGe-2 is metric, so its per-frame FOV guess can only introduce a scale; the
scale+shift version was measured to be ill-conditioned on the narrow depth
range a robot mask spans (``a`` wandered 0.037-1.08, CV 0.84, against CV
0.017 for scale-only).
"""
r = dst / np.maximum(src, 1e-6)
a = float(np.median(r))
return a, float(np.median(np.abs(a * src - dst)))
def render_and_composite(
rend, robot, joint_row: np.ndarray, gripper_value: float,
frame_bgr: np.ndarray, moge: np.ndarray, camera, depth_tol_m: float, last_a: float,
*, background_bgr: np.ndarray | None = None,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, float, float]:
"""One frame of the appearance-pair composite: render, depth-align, occlude, draw.
Extracted from :meth:`ClipBuilder.render_loop` (formerly inlined in
``ClipBuilder.build``) so a second caller can drive it with a gripper value
that did not come from ``trajectory.h5`` -- see
``scripts/render_counterfactual_grip.py``, which renders the *same* clip
with the recorded arm pose but an overridden gripper trace. Nothing here
changed: this is the original loop body with its four locals promoted to
parameters and its five locals promoted to a return tuple.
``background_bgr``, if given, replaces ``frame_bgr`` as the base the robot
is painted onto -- ``frame_bgr`` itself is still required (and still drives
the MoGe depth-alignment fit's caller-side inputs, unchanged) but is then
otherwise unused for that frame. This exists for
``scripts/render_background_probe.py``: every appearance pair ever built
(800 clips, the whole training set) uses the real camera frame as its
background, which hands the model the table/drawer/object for free and
leaves open whether the fine-tune can generate a scene at all, as opposed
to only knowing how to repaint a robot onto one it was already given. A
control with a synthetic background (a temporal-median plate, flat grey,
flat black) answers that, without touching the 800 pairs already built on
the real-frame path. **Default (``None``) reproduces the exact prior
behaviour** -- ``out = frame_bgr.copy()`` -- byte for byte: this is a
strict superset of the old signature, not a new codepath, and every
existing caller (``ClipBuilder.render_loop`` and
``render_counterfactual_grip.py``) calls it positionally without this
keyword and is therefore unaffected.
Returns:
``(composited_bgr, drawn_mask, occluded_mask, a, resid)`` where ``a`` is
the MoGe->render depth scale actually used this frame (freshly fit, or
carried over from ``last_a`` when too few core pixels supported a fit)
and ``resid`` is its residual (``nan`` when carried over). ``drawn`` and
``occluded`` depend only on the robot render and the MoGe depth, never
on ``frame_bgr``/``background_bgr`` -- so they are identical whichever
background was composited under them; ``render_background_probe.py``
checks this rather than assuming it.
"""
rr = rend.render(robot.link_poses(joint_row, float(gripper_value)), camera)
core = cv2.erode(rr.mask.astype(np.uint8), _ERODE).astype(bool)
fit = core & np.isfinite(moge) & (rr.depth > 0)
if fit.sum() >= 200:
a, resid = robust_scale(moge[fit], rr.depth[fit])
else:
a, resid = last_a, float("nan") # hold the last good scale
aligned = a * moge
drawn = rr.mask & (rr.depth > 0)
occluded = drawn & np.isfinite(aligned) & (rr.depth >= aligned + depth_tol_m)
out = (frame_bgr if background_bgr is None else background_bgr).copy()
win = drawn & ~occluded
out[win] = rr.color[win][:, ::-1] # RenderResult.color is RGB
return out, drawn, occluded, a, resid
class ClipBuilder:
"""Holds the URDF and one renderer across every clip of a run.
Both are expensive to construct (mesh load + EGL context) and neither depends
on the episode, so they are built once and reused.
"""
def __init__(self, urdf: Path, table: dict, depth_tol_m: float, crf: int) -> None:
from fpgm.robot.urdf import RobotModel
self.robot = RobotModel(str(urdf), load_meshes=True)
self.meshes = self.robot.visual_meshes()
self.table = table
self.depth_tol_m = depth_tol_m
self.crf = crf
self._renderer = None
self._size: tuple[int, int] | None = None
def renderer(self, w: int, h: int):
from fpgm.robot.render import RobotRenderer
if self._size != (w, h):
if self._renderer is not None:
self._renderer.close()
self._renderer = RobotRenderer(self.meshes, w, h)
self._size = (w, h)
return self._renderer
def close(self) -> None:
if self._renderer is not None:
self._renderer.close()
def load_clip(self, depth_meta: dict, episodes_root: Path, cameras_dir: Path) -> dict:
"""Every per-clip input the render loop needs, minus the depth array.
Split out of :meth:`build` so a caller that wants to render this same
clip with something other than the recorded gripper trace (see
``scripts/render_counterfactual_grip.py``) can get ``joint_positions``,
the real ``gripper`` trace, the camera and the frame/row alignment
without duplicating the extrinsics-selection and fps-ratio logic --
both have already bitten this pipeline once each (see the module and
``frames_per_step`` docstrings) and should not get a second copy to
drift out of sync.
"""
from fpgm.datagen.robot_buffers import (
TRAJECTORY_GRIPPER_POSITION_KEY,
TRAJECTORY_JOINT_POSITIONS_KEY,
load_extrinsics_candidates,
)
from fpgm.geometry.camera import Camera
uuid, serial = depth_meta["uuid"], depth_meta["camera_serial"]
ep = episodes_root / uuid
traj_path = ep / "trajectory.h5"
mp4 = REPO_ROOT / depth_meta["mp4"]
src_w, src_h = depth_meta["source_wh"]
out_w, out_h = depth_meta["video_wh"]
n = depth_meta["n_frames"]
with h5py.File(traj_path, "r") as f:
joint_positions = np.asarray(f[TRAJECTORY_JOINT_POSITIONS_KEY])
gripper = np.asarray(f[TRAJECTORY_GRIPPER_POSITION_KEY])
n_rows = joint_positions.shape[0]
# The mp4-fps trap: the container advertises 60/1 but its frames are 15 Hz
# trajectory samples. The only trustworthy map is the frame-count ratio.
cap_probe = cv2.VideoCapture(str(mp4))
n_video = int(cap_probe.get(cv2.CAP_PROP_FRAME_COUNT)) or n
cap_probe.release()
frames_per_step = n_video / n_rows
intr_src, intr_source = intrinsics_for(self.table, serial, src_w, src_h)
intr = crop_intrinsics(intr_src, out_w, out_h)
cands = load_extrinsics_candidates(
cameras_dir / f"{uuid}_cameras.json", traj_path, serial)
chosen = next((c for c in cands if c.name == "optimized_cameras_json"), cands[0])
camera = Camera(intr, chosen.world_to_cam)
return {
"uuid": uuid, "serial": serial, "mp4": mp4, "traj_path": traj_path,
"joint_positions": joint_positions, "gripper": gripper, "n_rows": n_rows,
"frames_per_step": frames_per_step, "camera": camera,
"out_w": out_w, "out_h": out_h, "n": n,
"intr_source": intr_source, "intr_src": intr_src, "extrinsics": chosen.name,
}
def render_loop(
self, joint_positions: np.ndarray, gripper: np.ndarray, mp4: Path, camera,
frames_per_step: float, n: int, depth_all, out_w: int, out_h: int,
out_dir: Path, fps: float, *, gripper_reference: np.ndarray | None = None,
) -> dict:
"""Per-frame render + MoGe-occlusion composite. Writes ``control.mp4`` /
``target.mp4`` into ``out_dir`` and returns the stats :meth:`build` folds
into ``meta.json``.
``gripper_reference``, if given, is a second gripper trace (same length
and row-indexing as ``gripper``) rendered alongside it purely to measure
how far the two diverge on screen -- ``render_counterfactual_grip.py``
passes the real recorded trace here while rendering the overridden one,
to quantify how much of the real arm/gripper is left showing through the
composite where the two silhouettes disagree. It costs one extra render
per frame and changes no output when omitted (the default, and what
:meth:`build` uses), so this reproduces the loop it was extracted from.
"""
n_rows = joint_positions.shape[0]
rend = self.renderer(out_w, out_h)
stats = {"a": [], "resid": [], "reject": [], "robot_px": []}
diff = {"extra_real_px": [], "drawn_real_px": []} if gripper_reference is not None else None
last_a = 1.0
control_w = H264Writer(out_dir / "control.mp4", out_w, out_h, fps, self.crf)
target_w = H264Writer(out_dir / "target.mp4", out_w, out_h, fps, self.crf)
cap = cv2.VideoCapture(str(mp4))
try:
for t in range(n):
ok, frame_bgr = cap.read()
if not ok:
break
frame_bgr = cover_crop(frame_bgr, out_w, out_h)
target_w.write(frame_bgr)
row = min(int(round(t / frames_per_step)), n_rows - 1)
moge = cv2.resize(depth_all[t].astype(np.float32), (out_w, out_h),
interpolation=cv2.INTER_NEAREST)
out, drawn, occluded, last_a, resid = render_and_composite(
rend, self.robot, joint_positions[row], gripper[row],
frame_bgr, moge, camera, self.depth_tol_m, last_a)
stats["a"].append(last_a)
stats["resid"].append(resid)
stats["robot_px"].append(int(drawn.sum()))
stats["reject"].append(int(occluded.sum()))
control_w.write(out)
if diff is not None:
rr_ref = rend.render(
self.robot.link_poses(joint_positions[row],
float(gripper_reference[row])),
camera)
drawn_ref = rr_ref.mask & (rr_ref.depth > 0)
diff["extra_real_px"].append(int((drawn_ref & ~drawn).sum()))
diff["drawn_real_px"].append(int(drawn_ref.sum()))
finally:
cap.release()
control_w.close()
target_w.close()
if diff is not None:
stats["diff"] = diff
return stats
def build(self, depth_meta: dict, work: Path, out_root: Path,
episodes_root: Path, cameras_dir: Path, fps: float) -> dict:
uuid, serial = depth_meta["uuid"], depth_meta["camera_serial"]
clip = self.load_clip(depth_meta, episodes_root, cameras_dir)
joint_positions, gripper = clip["joint_positions"], clip["gripper"]
mp4, camera = clip["mp4"], clip["camera"]
frames_per_step, n = clip["frames_per_step"], clip["n"]
out_w, out_h = clip["out_w"], clip["out_h"]
intr_src, intr_source = clip["intr_src"], clip["intr_source"]
depth_all = np.load(work / f"{uuid}__{serial}.depth.npy", mmap_mode="r")
out_dir = out_root / f"{uuid}__{serial}"
out_dir.mkdir(parents=True, exist_ok=True)
stats = self.render_loop(joint_positions, gripper, mp4, camera, frames_per_step,
n, depth_all, out_w, out_h, out_dir, fps)
ep = episodes_root / uuid
ep_meta = json.loads((ep / "metadata.json").read_text())
src_w, src_h = depth_meta["source_wh"]
a = np.asarray(stats["a"])
robot_px = max(int(np.sum(stats["robot_px"])), 1)
meta = {
"uuid": uuid, "camera_serial": serial, "n_frames": len(stats["a"]),
"fps": fps, "video_wh": [out_w, out_h], "source_wh": [src_w, src_h],
"crf": self.crf,
"caption": ep_meta.get("current_task"), "lab": ep_meta.get("lab"),
"control": rel(out_dir / "control.mp4"),
"target": rel(out_dir / "target.mp4"),
"source_mp4": rel(mp4),
"intrinsics_source": intr_source,
"intrinsics_fx_over_w": round(intr_src.fx / src_w, 5),
"moge_fx_over_w_median": depth_meta["moge_fx_over_w_median"],
"extrinsics": clip["extrinsics"],
"frames_per_step": round(frames_per_step, 5),
"scale_a_median": round(float(np.median(a)), 4),
"scale_a_p10_p90": [round(float(np.percentile(a, 10)), 4),
round(float(np.percentile(a, 90)), 4)],
"align_residual_m_median": round(float(np.nanmedian(stats["resid"])), 4),
"robot_px_median": int(np.median(stats["robot_px"])),
"occluded_px_fraction": round(float(np.sum(stats["reject"]) / robot_px), 5),
"depth_tol_m": self.depth_tol_m,
}
(out_dir / "meta.json").write_text(json.dumps(meta, indent=2))
return meta
def main() -> None:
ap = argparse.ArgumentParser(description=__doc__,
formatter_class=argparse.RawDescriptionHelpFormatter)
ap.add_argument("--work", type=Path, required=True)
ap.add_argument("--out", type=Path, default=REPO_ROOT / "outputs/appearance_pairs")
ap.add_argument("--episodes-root", type=Path, default=REPO_ROOT / "data/droid_raw")
ap.add_argument("--cameras-dir", type=Path,
default=REPO_ROOT / "data/pointworld/droid/cameras")
ap.add_argument("--intrinsics", type=Path,
default=REPO_ROOT / "configs/droid_camera_intrinsics.json")
ap.add_argument("--urdf", type=Path, default=None)
ap.add_argument("--depth-tol-m", type=float, default=0.15)
ap.add_argument("--crf", type=int, default=18)
ap.add_argument("--fps", type=float, default=_TRAJECTORY_FPS)
ap.add_argument("--follow", action="store_true",
help="keep consuming until every stage-1 shard drops its "
"STAGE1_DONE_* marker")
ap.add_argument("--expect-shards", type=int, default=1)
ap.add_argument("--keep-depth", action="store_true",
help="do not delete each depth npy after consuming it")
args = ap.parse_args()
urdf = args.urdf
if urdf is None:
from fpgm.config_datagen import DatagenProfile
urdf = Path(DatagenProfile.from_yaml(
str(REPO_ROOT / "configs/datagen_droid.yaml")).paths.urdf)
builder = ClipBuilder(urdf, load_intrinsic_table(args.intrinsics),
args.depth_tol_m, args.crf)
args.out.mkdir(parents=True, exist_ok=True)
done, failed, frames, t0 = 0, 0, 0, time.time()
try:
while True:
todo = [p for p in sorted(args.work.glob("*.depth.json"))
if p.with_suffix(".npy").exists()
and not (args.out / p.name.replace(".depth.json", "")
/ "meta.json").exists()]
if not todo:
if not args.follow or len(list(args.work.glob("STAGE1_DONE_*"))) \
>= args.expect_shards:
break
time.sleep(2) # poll interval: short enough not to show up in
continue # the tail latency of a run that is nearly done
for meta_path in todo:
dm = json.loads(meta_path.read_text())
try:
m = builder.build(dm, args.work, args.out, args.episodes_root,
args.cameras_dir, args.fps)
done += 1
frames += m["n_frames"]
print(f"[{done}] {m['uuid']}/{m['camera_serial']} "
f"n={m['n_frames']} a={m['scale_a_median']:.3f} "
f"resid={m['align_residual_m_median']*1000:.1f}mm "
f"occl={m['occluded_px_fraction']*100:.2f}% "
f"intr={m['intrinsics_source']}", flush=True)
except Exception as exc:
failed += 1
print(f" FAILED {dm['uuid']}/{dm['camera_serial']}: "
f"{type(exc).__name__}: {exc}", flush=True)
if not args.keep_depth:
meta_path.with_suffix(".npy").unlink(missing_ok=True)
finally:
builder.close()
dt = time.time() - t0
print(f"stage 2 done: {done} clips, {failed} failed, {dt/60:.1f} min "
f"({dt/max(done,1):.1f} s/clip, {frames/max(dt,1e-9):.1f} fps)", flush=True)
if __name__ == "__main__":
main()

Xet Storage Details

Size:
24.1 kB
·
Xet hash:
38c5740fdd9cddb77c8dffac40968db972336b1f2a8687356d1f5874554e6b23

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