twanghcmut/backup-foundation-physics / scripts /render_counterfactual_held.py
twanghcmut's picture
download
raw
44 kB
#!/usr/bin/env python
"""Counterfactual: after the grasp, the gripper never opens -- the brick rides in the
jaws for the rest of the episode instead of being placed in the drawer.
--- Why this is a different (and harder) experiment than the earlier gripper probe ---
A previous attempt (``scripts/render_counterfactual_grip.py``, a different episode)
overrode only the gripper *joint signal* and re-rendered the robot over the REAL
camera frame (``scripts/appearance_control.py``'s compositor). Measured there: the
resulting control differed from the real control by only 0.29-1.32% of pixels -- just
the two finger pads. The brick in that control was still the real, photographed brick
from the real video, so the generated clip showed the brick being released regardless
of what the rendered fingers did, and the experiment could not say whether the
generative model follows the control at all.
The fix is not a bigger override of the same kind -- it is rendering a DIFFERENT
BRICK TRAJECTORY. This script overrides the brick's *pose track*, not just the
gripper's, and composites everything (robot + repositioned brick + drawer) over
``master/plate.png`` -- a background plate with the real moving foreground already
removed (:mod:`fpgm.datagen.plate`) -- via the existing S8 exporter
(:class:`fpgm.datagen.export_vace.VaceExportStage`), which is exactly the "re-render
from poses + meshes, never from an observed photo" path S8 was built for (see that
module's own docstring). Nothing here draws over a real frame, so nothing here can
leak the real brick's real trajectory into the control.
--- What actually changes, precisely ---
1. ``master/events.json``'s ``brick.attached`` (S7, computed from poses alone --
see :mod:`fpgm.datagen.events`) locates the grasp: the first frame the brick
is rigidly attached to the gripper. This is a HEURISTIC symptom detector (that
module's own words), but it is the one already used, in this exact codebase, to
decide FK-attach bridging for occluded frames (``fpgm.datagen.object_poses.
_bridge_gaps``) -- reusing it here keeps "grasp frame" consistent with what the
rest of the pipeline already means by the term, rather than inventing a second
detector.
2. From that frame on, ``T_gripper_brick = inv(T_world_gripper(grasp)) @
T_world_brick(grasp)`` is fixed once, then ``T_world_brick(t) = T_world_gripper(t)
@ T_gripper_brick`` for every later frame -- the exact rigid-attach formula
``_bridge_gaps`` already uses to bridge an occluded grasp span, applied here to
EVERY frame from the grasp onward rather than only the occluded ones. Frames
before the grasp keep the real, PnP-measured pose exactly.
3. ``T_world_gripper(t)`` comes from :meth:`fpgm.robot.kinematics.ArmKinematics.fk`
on the REAL recorded arm-joint trajectory (untouched) and a gripper value that is
real up to the grasp frame and FROZEN at its own closed value from there on --
the same ``ArmKinematics.fk``-based TCP path S6/S7 already use to build
``T_world_tcp`` for ``attach_score`` (see ``fpgm.datagen.pipeline._run_s6``),
so the FK feeding this override is the same FK that decided where the grasp is.
4. The brick's pose-source is ALSO overridden to ``FK_ATTACH`` (not left at whatever
S6 originally recorded) from the grasp frame through the end of the episode.
On the real footage, S6 could not solve the brick's pose past frame 87 (SAM3D
PnP has nothing to solve once the brick is inside the drawer -- pose_source is
GAP for 87-126, and S8 honestly does not render an object on a GAP frame). The
counterfactual's whole premise is that the brick stays in view, gripped, so it
MUST be rendered on those frames -- FK_ATTACH says so truthfully (this pose is a
rigid-attach derivation, not a re-solved measurement), matching the enum's own
documented meaning ("bridged via forward-kinematics while grasped").
--- What does NOT change, and the one place this leaves the scene inconsistent ---
The drawer is untouched: it keeps sliding on its own real, measured
(``drawer__T_world_obj``) track, including closing near the end of the episode, on
schedule, exactly as filmed. In the real episode the drawer closes *because* the
brick was just placed inside it and the arm is done with it; in this counterfactual
the brick never went in, yet the drawer still closes as if it had. This is a
deliberate scope limitation, not a bug: fixing it would mean inventing a
counterfactual drawer trajectory this episode's data says nothing about (the
episode's own robot never interacts with the drawer any differently once it is NOT
carrying a brick to it), which is a different, larger claim than "the gripper stays
closed." The rendered control is therefore physically self-consistent about the one
thing the intervention actually targets (gripper + brick) and openly inconsistent
about a second-order consequence (why would the drawer still close) it does not
attempt to model. Reported plainly here and in the run's own JSON report, not hidden.
--- Reuse, and why not more of it ---
Camera/robot/trajectory loading below mirrors ``fpgm.datagen.pipeline.run_s8_export``
line for line (same ``FlowsReader``/``EpisodeFrameIndex``, same extrinsics-candidate
selection, same ``RobotModel``/``ArmKinematics`` construction) because that IS the
production recipe for "S2's validated camera + S6's FK poses for this episode" and
reimplementing it differently here would risk silently disagreeing with the
``events.json`` this script depends on. :class:`~fpgm.datagen.export_vace.
VaceExportStage` itself is called unmodified -- the one thing overridden is the
in-memory ``object_T_world_obj["brick"]``/``object_pose_source["brick"]`` arrays
handed to it, exactly the extension point :meth:`VaceExportStage.run` already
exposes for a caller-supplied pose track. No second compositor is written.
**Why S8's per-window export, then a stitch, instead of a bespoke one-shot
renderer.** S8's window schedule (:func:`~fpgm.datagen.export_vace.compute_windows`)
is fixed at ``<=81`` frames -- Wan2.1-VACE's own ``4n+1, capped at 81`` training
contract -- so a single call cannot emit one continuous 127-frame control; this
episode always splits into windows ``[0,81) [40,121) [46,127)``. But
``scripts/sample_appearance_lora.py`` wants exactly one continuous
``(control.mp4, target.mp4)`` pair and does its OWN chunking at generation time
(``--chunk-frames``/``--chain``, confirmed to auto-chunk a >81-frame pair without the
caller pre-splitting). So this script exports the three S8 windows unmodified, then
stitches them back into one 127-frame pair by keeping, for each global frame, the
first window that ever covers it -- since a window's render depends only on that
single frame's own poses/camera/assets (no cross-frame filtering), any one of the
overlapping windows' renders of a shared frame is the same render.
**Which S8 channel becomes ``control.mp4``.** S8 writes five channels per window;
``control_vis.mp4`` -- "the renderer's own RGB, composited over the real plate" -- is
the one this project's own Wan-VACE ablation measured as the usable control (bg PSNR
22.99 vs 9-10 for the raw depth/seg/normal buffers, see ``export_vace.py``'s own
docstring), and it is also the only S8 channel that is an ordinary photograph-like
RGB video -- exactly ``AppearancePairDataset``'s ``control.mp4`` contract
(``fpgm.training.appearance_dataset``), unlike ``control_depth.mkv``/``control_seg.mkv``
(FFV1, integer-id semantics, a different dataset class entirely). Renaming
``control_vis.mp4`` to ``control.mp4`` in the stitched pair directory is the entire
translation needed between the two formats -- no new compositing.
--- Two honesty notes carried into every run's ``report.json`` ---
* ``VaceExportStage.run``'s on-disk cache fingerprint does not include object pose
content (only uuid/camera/window range/caption/resolution/code_version) -- reusing
the SAME output root for two different pose overrides would silently serve a stale
render. This script always passes ``force=True`` and always uses a fresh,
isolated ``out_root`` (never the episode's own ``outputs/datagen/<uuid>/<camera>/``
tree, which the 9 production datagen episodes and 800 built training pairs
already depend on).
* DiffSynth generation is NOT bit-reproducible across process launches even at a
fixed seed (measured MAE ~2.0 between two launches of the same checkpoint on an
identical control -- a floor this run's own report repeats verbatim so nobody
reads a small step-500-vs-step-2000 difference as meaningful).
Usage (env ``fpgm`` -- this process does the pyrender/EGL render itself and only
shells out to env ``wan-train`` for the diffusion generation step, same
separation-of-concerns as ``render_counterfactual_grip.py``)::
PYOPENGL_PLATFORM=egl PYTHONPATH=src /home/quang/miniconda3/envs/fpgm/bin/python \\
scripts/render_counterfactual_held.py --gpu 0
"""
from __future__ import annotations
import argparse
import json
import os
import subprocess
import sys
from pathlib import Path
from typing import TYPE_CHECKING
if TYPE_CHECKING:
import numpy as np
REPO_ROOT = Path(__file__).resolve().parents[1]
sys.path.insert(0, str(REPO_ROOT / "src"))
_DEFAULT_UUID = "AUTOLab+0d4edc83+2023-10-21-19h-07m-04s"
_DEFAULT_SERIAL = "22008760"
_WAN_PY = "/home/quang/miniconda3/envs/wan-train/bin/python"
_DEFAULT_CONFIG = REPO_ROOT / "configs" / "datagen_droid.yaml"
#: Pixel-value threshold (0-255, per channel, on decoded RGB) above which a pixel is
#: counted as "changed" between the factual and counterfactual control. Not zero,
#: because both controls independently pass through the same lossy H.264 encode/decode
#: (crf from ``DatagenConfig.control_video_crf``) even on frames with literally
#: identical geometry -- a bare ``!=`` would report a nonzero "difference" on every
#: single frame from compression noise alone. 10/255 is comfortably above typical H.264
#: banding at this crf and comfortably below "a brick rendered at a different 3D pose",
#: which moves a foreground region by tens to low-hundreds of pixel values, not single
#: digits.
_DIFF_PIXEL_THRESHOLD = 10
# --------------------------------------------------------------------------- #
# Episode context: mirrors fpgm.datagen.pipeline.run_s8_export's own loading
# --------------------------------------------------------------------------- #
def _load_context(uuid: str, camera_serial: str, config_path: Path, timer=None):
"""Load everything S8 already loads for this episode: camera, robot, trajectory.
Deliberately the same recipe as ``fpgm.datagen.pipeline.run_s8_export`` (see this
module's own docstring on why re-deriving a *different* camera/FK path here would
risk silently disagreeing with ``events.json``, which was computed through this
exact FK). Returns a plain dict rather than a new dataclass -- this script has one
call site for each field, so a dataclass would only add indirection.
"""
import h5py
import numpy as np
from fpgm.config_datagen import DatagenProfile
from fpgm.data.droid_raw import read_mp4_properties
from fpgm.data.pointworld import FlowsReader
from fpgm.datagen.frame_index import EpisodeFrameIndex
from fpgm.datagen.pipeline import _episode_gates_for_s8, _load_object_geometry
from fpgm.datagen.robot_buffers import (
TRAJECTORY_GRIPPER_POSITION_KEY,
TRAJECTORY_JOINT_POSITIONS_KEY,
load_extrinsics_candidates,
read_native_camera_intrinsics,
)
from fpgm.robot.kinematics import ArmKinematics
from fpgm.robot.urdf import RobotModel
from fpgm.types import DataError
with (timer.step("load_context") if timer else _noop()):
profile = DatagenProfile.from_yaml(str(config_path))
master_dir = profile.paths.master_dir(uuid, camera_serial)
episode_dir = profile.paths.episode_dir(uuid)
mp4_path = episode_dir / "recordings" / "MP4" / f"{camera_serial}.mp4"
trajectory_path = episode_dir / "trajectory.h5"
if not mp4_path.exists():
raise DataError(f"mp4 not found: {mp4_path}")
if not trajectory_path.exists():
raise DataError(f"trajectory.h5 not found: {trajectory_path}")
mp4_fps, n_video_frames, (video_w, video_h) = read_mp4_properties(mp4_path)
with FlowsReader(profile.paths.flows_h5(uuid), episode_uuid=uuid) as reader:
frame_index = EpisodeFrameIndex.build(
uuid, reader, trajectory_path,
mp4_properties=(mp4_fps, n_video_frames, (video_w, video_h)),
camera_serial=camera_serial,
)
native_intrinsics = read_native_camera_intrinsics(
profile.paths.flows_h5(uuid), camera_serial
).scaled(video_w, video_h)
extrinsics_candidates = load_extrinsics_candidates(
profile.paths.cameras_json(uuid), trajectory_path, camera_serial
)
s2_meta = json.loads((master_dir / "robot_buffers" / "meta.json").read_text())
chosen_name = s2_meta["payload"]["chosen_extrinsics"]
chosen = next(c for c in extrinsics_candidates if c.name == chosen_name)
if not profile.paths.urdf.exists():
raise DataError(f"URDF not found: {profile.paths.urdf}")
robot = RobotModel(str(profile.paths.urdf), load_meshes=True)
kin = ArmKinematics.from_robot_model(robot)
with h5py.File(trajectory_path, "r") as f:
joint_positions = np.asarray(f[TRAJECTORY_JOINT_POSITIONS_KEY])
gripper_real = np.asarray(f[TRAJECTORY_GRIPPER_POSITION_KEY])
import cv2
with h5py.File(master_dir / "background_depth.h5", "r") as f:
background_depth_mm = np.asarray(f["depth_mm"])
plate_bgr = cv2.imread(str(master_dir / "plate.png"))
if plate_bgr is None:
raise DataError(f"could not read {master_dir / 'plate.png'}")
background_plate_rgb = cv2.cvtColor(plate_bgr, cv2.COLOR_BGR2RGB)
poses = np.load(master_dir / "poses.npz", allow_pickle=False)
object_labels = tuple(str(x) for x in poses["labels"])
object_T_world_obj = {label: poses[f"{label}__T_world_obj"] for label in object_labels}
object_pose_source = {label: poses[f"{label}__pose_source"] for label in object_labels}
object_meshes, point_cloud_objects = _load_object_geometry(
master_dir / "meshes", list(object_labels)
)
episode_spec = json.loads((master_dir / "episode_spec.json").read_text())
task_string = episode_spec.get("task") or f"robot manipulation episode {uuid}"
events = json.loads((master_dir / "events.json").read_text())
gates_passed = _episode_gates_for_s8(master_dir)
return {
"profile": profile, "uuid": uuid, "camera_serial": camera_serial,
"master_dir": master_dir, "mp4_path": mp4_path, "mp4_fps": mp4_fps,
"n_video_frames": n_video_frames, "video_w": video_w, "video_h": video_h,
"frame_index": frame_index, "native_intrinsics": native_intrinsics,
"world_to_cam": chosen.world_to_cam, "robot": robot, "kin": kin,
"joint_positions": joint_positions, "gripper_real": gripper_real,
"background_depth_mm": background_depth_mm, "background_plate_rgb": background_plate_rgb,
"object_labels": object_labels, "object_T_world_obj": object_T_world_obj,
"object_pose_source": object_pose_source, "object_meshes": object_meshes,
"point_cloud_objects": point_cloud_objects, "task_string": task_string,
"events": events, "gates_passed": gates_passed,
}
class _noop:
def __enter__(self):
return None
def __exit__(self, *a):
return False
# --------------------------------------------------------------------------- #
# Grasp frame + rigid attachment
# --------------------------------------------------------------------------- #
def find_grasp_frame(events: dict, label: str = "brick") -> int:
"""First frame ``events.json``'s hysteresis-gated ``attached`` is True for ``label``.
See the module docstring's point 1 for why ``events.json`` (S7, pose-derived,
already computed) is reused rather than a second, script-local detector -- this
is the exact same signal :func:`fpgm.datagen.object_poses._bridge_gaps` already
conditions FK-attach bridging on.
Raises:
ValueError: If ``label`` is never attached in this episode -- nothing to
hold onto for a "held" counterfactual.
"""
attached = events[label]["attached"]
for t, a in enumerate(attached):
if a:
return t
raise ValueError(f"events.json: {label!r} is never attached in this episode")
def _resample_to_video_frames(
row_indexed: np.ndarray, frame_index, n_video_frames: int
) -> np.ndarray:
"""``row_indexed`` (DROID trajectory-row axis) -> one value per video frame.
Always through ``frame_index.trajectory_row`` -- never a raw fps-ratio division.
``fpgm.datagen.frame_index``'s own docstring documents this as a previously-real
bug (rounding non-invertibility producing blank frames); every other loader in
this pipeline (S6/S8's own ``joint_positions[row]``/``gripper[row]`` loops) goes
through this exact same lookup, so this helper does too.
"""
import numpy as np
return np.asarray(
[row_indexed[frame_index.trajectory_row(t)] for t in range(n_video_frames)],
dtype=np.float64,
)
def build_gripper_signal_and_world_gripper(
ctx: dict, grasp_frame: int,
) -> tuple[np.ndarray, np.ndarray, np.ndarray, float]:
"""Per-video-frame gripper values (real, and held-from-grasp) + T_world_gripper.
``T_world_gripper(t) = kin.fk(joint_positions[row(t)], gripper_cf(t))`` -- the
same ``ArmKinematics.fk`` TCP path S6/S7 already use to build ``T_world_tcp`` for
``attach_score`` (see module docstring point 3). The arm-joint trajectory itself
is the real recorded one at every frame, before and after the grasp -- only the
gripper argument ever differs from the real signal.
Returns:
``(gripper_cf, T_world_gripper, gripper_real_by_frame, closed_value)`` --
the first three all length ``n_video_frames``; ``closed_value`` is the real
signal's own value at the grasp frame (reported, not invented -- see this
function's own measured note below). ``gripper_real_by_frame`` is returned
too so the factual render can use the identical video-frame-indexed
resampling as the counterfactual one, rather than a second, ad hoc mapping
at the call site.
"""
import numpy as np
frame_index = ctx["frame_index"]
n = ctx["n_video_frames"]
joint_positions = ctx["joint_positions"]
kin = ctx["kin"]
gripper_real_by_frame = _resample_to_video_frames(ctx["gripper_real"], frame_index, n)
closed_value = float(gripper_real_by_frame[grasp_frame])
# MEASURED on this episode (not assumed): the recorded gripper signal is already
# at its own plateau by the grasp frame -- 0.2599 -> 0.2863 between rows 43 and
# 44, then flat at 0.2863 through row 60 -- so freezing at the grasp frame's own
# value, rather than walking forward to a later plateau
# (``render_counterfactual_grip.py``'s approach, needed there because ITS grasp
# frame was picked from a bare threshold crossing mid-ramp), does not truncate
# the real closing motion here: ``events.json``'s ``attached`` already only turns
# True once ``attach_score``'s hysteresis (on_threshold=0.6) has cleared, which on
# this episode happens to land past the ramp already.
gripper_cf = gripper_real_by_frame.copy()
gripper_cf[grasp_frame:] = closed_value
t_world_gripper = np.empty((n, 4, 4), dtype=np.float64)
for t in range(n):
row = frame_index.trajectory_row(t)
t_world_gripper[t] = kin.fk(joint_positions[row], float(gripper_cf[t]))
return gripper_cf, t_world_gripper, gripper_real_by_frame, closed_value
def build_held_brick_track(
ctx: dict, grasp_frame: int, t_world_gripper: np.ndarray,
) -> tuple[np.ndarray, np.ndarray]:
"""Rigidly attach the brick to the gripper from ``grasp_frame`` on.
``T_gripper_brick`` is fixed once, at the grasp frame's own real (PnP-measured)
brick pose -- verified ``PoseSource.PNP`` below, not assumed, since the formula
is meaningless applied to an already-fabricated pose. From there,
``T_world_brick(t) = T_world_gripper(t) @ T_gripper_brick`` for every later
frame, exactly ``fpgm.datagen.object_poses._bridge_gaps``'s own FK-attach
formula (see module docstring point 2), applied here through the END of the
episode rather than only across one occluded gap.
Returns:
``(t_world_brick_cf, pose_source_cf)``, both length ``n_video_frames``.
Frames before ``grasp_frame`` are copied through unchanged, real values.
"""
from fpgm.datagen.types import PoseSource
from fpgm.geometry.transforms import invert_se3
from fpgm.types import DataError
t_world_brick_real = ctx["object_T_world_obj"]["brick"]
pose_source_real = ctx["object_pose_source"]["brick"]
if int(pose_source_real[grasp_frame]) != int(PoseSource.PNP):
raise DataError(
f"grasp frame {grasp_frame}: brick pose_source is "
f"{PoseSource(int(pose_source_real[grasp_frame])).name}, not PNP -- "
"T_gripper_brick would be anchored to a fabricated (not measured) pose"
)
t_gripper_brick = invert_se3(t_world_gripper[grasp_frame]) @ t_world_brick_real[grasp_frame]
t_world_brick_cf = t_world_brick_real.copy()
pose_source_cf = pose_source_real.copy()
n = t_world_brick_real.shape[0]
for t in range(grasp_frame, n):
t_world_brick_cf[t] = t_world_gripper[t] @ t_gripper_brick
pose_source_cf[t] = PoseSource.FK_ATTACH
return t_world_brick_cf, pose_source_cf
# --------------------------------------------------------------------------- #
# S8 render (unmodified VaceExportStage) + stitch into one continuous pair
# --------------------------------------------------------------------------- #
def render_link_poses_for_frames(ctx: dict, gripper_by_frame: np.ndarray) -> list:
"""FK link poses for every video frame, given an already video-frame-indexed gripper trace.
``gripper_by_frame`` must already be resampled onto the video-frame axis (see
:func:`_resample_to_video_frames`) -- this function only remaps ``joint_positions``
(always trajectory-row-indexed) through ``frame_index.trajectory_row``, so the
same call site works uniformly for the real signal (factual branch) and the
frozen-from-grasp signal (counterfactual branch): the rendered fingers must match
whichever ``T_world_gripper`` the brick was rigidly attached to, or the control
would show the brick floating next to open jaws.
"""
link_poses_by_frame = []
for t in range(ctx["n_video_frames"]):
row = ctx["frame_index"].trajectory_row(t)
link_poses_by_frame.append(
ctx["robot"].link_poses(ctx["joint_positions"][row], float(gripper_by_frame[t]))
)
return link_poses_by_frame
def render_windows_with_link_poses(
ctx: dict, *, object_T_world_obj: dict, object_pose_source: dict,
link_poses_by_frame: list, out_root: Path, caption: str, timer=None, step_name: str = "render",
) -> list:
"""Call the unmodified S8 exporter with a (possibly overridden) object pose track.
The only things this function varies relative to ``fpgm.datagen.pipeline.
run_s8_export`` are ``object_T_world_obj``/``object_pose_source``/
``link_poses_by_frame`` -- everything else (camera, robot meshes, background,
gates, cfg) is this episode's own real, unmodified context. ``force=True``
always: see module docstring's honesty note on why the on-disk cache
fingerprint cannot tell a factual export apart from a counterfactual one at the
same uuid/camera/window.
"""
from fpgm.datagen.cache import StageCache
from fpgm.datagen.export_vace import VaceExportStage
with (timer.step(step_name, n=ctx["n_video_frames"]) if timer else _noop()):
stage = VaceExportStage()
samples = stage.run(
uuid=ctx["uuid"], camera_serial=ctx["camera_serial"], mp4_path=ctx["mp4_path"],
fps=ctx["mp4_fps"], n_video_frames=ctx["n_video_frames"],
native_intrinsics=ctx["native_intrinsics"], world_to_cam=ctx["world_to_cam"],
link_meshes=ctx["robot"].visual_meshes(), link_poses_by_frame=link_poses_by_frame,
background_depth_mm=ctx["background_depth_mm"],
background_plate_rgb=ctx["background_plate_rgb"], object_labels=ctx["object_labels"],
object_meshes=ctx["object_meshes"], object_T_world_obj=object_T_world_obj,
object_pose_source=object_pose_source, out_root=out_root, cfg=ctx["profile"].datagen,
caption=caption, gates_passed=ctx["gates_passed"], cache=StageCache(out_root),
force=True, caption_is_template_composed=True,
point_cloud_objects=ctx["point_cloud_objects"],
)
return samples
def stitch_pair(
samples: list, *, n_video_frames: int, uuid: str, camera_serial: str, caption: str,
fps: float, video_wh: tuple[int, int], counterfactual_note: str, out_dir: Path,
) -> Path:
"""Stitch S8's overlapping per-window ``control_vis.mp4``/``target.mp4`` into one pair.
See module docstring's "Reuse, and why not more of it" section for the full
reasoning: for each global frame, the first window that covers it wins (any
window's render of a shared frame is identical, since a frame's render depends
only on that frame's own poses/camera/assets). Writes
``out_dir/{control.mp4,target.mp4,meta.json}`` -- the exact contract
``fpgm.training.appearance_dataset.AppearancePairDataset``/
``scripts/sample_appearance_lora.py --pair`` expects.
"""
import cv2
import numpy as np
from fpgm.utils.io import ensure_dir
ensure_dir(out_dir)
windows = sorted(samples, key=lambda s: s.video_frame_start)
w, h = video_wh
control_frames: list = [None] * n_video_frames
target_frames: list = [None] * n_video_frames
covered = 0
for s in windows:
window_dir = s.target_video.parent
cap_c = cv2.VideoCapture(str(window_dir / "control_vis.mp4"))
cap_t = cv2.VideoCapture(str(window_dir / "target.mp4"))
try:
for i in range(s.video_frame_end - s.video_frame_start):
ok_c, fc = cap_c.read()
ok_t, ft = cap_t.read()
if not (ok_c and ok_t):
raise RuntimeError(f"{window_dir}: short read at local frame {i}")
g = s.video_frame_start + i
if g >= covered:
control_frames[g] = fc
target_frames[g] = ft
finally:
cap_c.release()
cap_t.release()
covered = max(covered, s.video_frame_end)
if covered < n_video_frames or any(f is None for f in control_frames):
raise RuntimeError(
f"stitch_pair: windows covered [0, {covered}) of {n_video_frames} -- "
"compute_windows should always reach the episode end"
)
def _write(path: Path, frames: list) -> None:
ff = "/home/quang/miniconda3/envs/ffmpeg_libs/bin/ffmpeg"
p = subprocess.Popen(
[ff, "-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", "-pix_fmt", "yuv420p", "-crf", "14", str(path)],
stdin=subprocess.PIPE,
)
for f in frames:
p.stdin.write(np.ascontiguousarray(f).tobytes())
p.stdin.close()
if p.wait() != 0:
raise RuntimeError(f"ffmpeg failed writing {path}")
_write(out_dir / "control.mp4", control_frames)
_write(out_dir / "target.mp4", target_frames)
(out_dir / "meta.json").write_text(json.dumps({
"uuid": uuid, "camera_serial": camera_serial, "caption": caption,
"n_frames": n_video_frames, "fps": fps, "video_wh": [w, h],
"counterfactual": counterfactual_note,
"control_source": "fpgm.datagen.export_vace.VaceExportStage's control_vis "
"channel (robot+objects rendered, composited over master/plate.png), "
"stitched across S8's own overlapping window schedule -- see "
"scripts/render_counterfactual_held.py's module docstring.",
}, indent=2))
return out_dir / "meta.json"
# --------------------------------------------------------------------------- #
# Verify: how much does the counterfactual control actually differ, per frame
# --------------------------------------------------------------------------- #
def control_diff_fractions(factual_control: Path, counterfactual_control: Path) -> dict:
"""Per-frame fraction of pixels that differ (beyond H.264 noise) between two controls.
This is the falsifiable check the module docstring exists to satisfy: the
earlier gripper-only override measured 0.29-1.32% of pixels different (just the
finger pads) because it never touched the brick. If this script's own numbers
come out at that same ~1%, the control is NOT depicting the counterfactual and
that must be reported as a failure, not glossed over on the way to a conclusion.
"""
import cv2
import numpy as np
cap_a = cv2.VideoCapture(str(factual_control))
cap_b = cv2.VideoCapture(str(counterfactual_control))
fracs: list[float] = []
try:
while True:
ok_a, fa = cap_a.read()
ok_b, fb = cap_b.read()
if not (ok_a and ok_b):
break
diff = np.abs(fa.astype(np.int16) - fb.astype(np.int16)).max(axis=2)
fracs.append(float(np.mean(diff > _DIFF_PIXEL_THRESHOLD)))
finally:
cap_a.release()
cap_b.release()
if not fracs:
raise RuntimeError("control_diff_fractions: no frames read from either control")
arr = np.asarray(fracs)
return {
"pixel_threshold": _DIFF_PIXEL_THRESHOLD,
"n_frames": len(fracs),
"per_frame_diff_fraction": fracs,
"mean_diff_fraction": float(arr.mean()),
"max_diff_fraction": float(arr.max()),
"min_diff_fraction": float(arr.min()),
}
# --------------------------------------------------------------------------- #
# Generation (env wan-train, via subprocess) + side-by-side
# --------------------------------------------------------------------------- #
def _pick_gpu(min_free_gb: float = 10.0) -> int:
"""Best-effort: the GPU with the most free memory, refusing to guess below a floor.
GPUs on this host are shared with other people (see the task brief) --
``nvidia-smi`` is queried at call time, never assumed from a config default, and
a low-memory choice is reported loudly rather than silently attempted and OOMing
a multi-minute generation job.
"""
out = subprocess.run(
["nvidia-smi", "--query-gpu=index,memory.free", "--format=csv,noheader,nounits"],
capture_output=True, text=True, check=True,
).stdout
rows = [line.split(",") for line in out.strip().splitlines()]
free_by_idx = {int(i): float(f) for i, f in rows}
best = max(free_by_idx, key=free_by_idx.get)
if free_by_idx[best] < min_free_gb * 1024:
raise SystemExit(
f"_pick_gpu: best candidate is GPU {best} with {free_by_idx[best]/1024:.1f} GB "
f"free, below the {min_free_gb:.0f} GB floor -- pass --gpu explicitly to override"
)
return best
def generate(
pair_dir: Path, out_dir: Path, *, gpu: int, num_frames: int, chunk_frames: int,
steps: int, cfg_scale: float, seed: int, loras: list[Path], offload_text_encoder: bool,
timer=None, step_name: str = "generate",
) -> None:
"""Two ``sample_appearance_lora.py`` launches: (baseline + loras[0]), then loras[1:].
One process per checkpoint pairing rather than one process per checkpoint: the
``--baseline`` zero-shot arm only needs computing once per control (it does not
depend on which LoRA step is being compared against it), and DiffSynth's model
load is the dominant fixed cost per launch.
"""
env = {**os.environ, "CUDA_VISIBLE_DEVICES": str(gpu)}
base_cmd = [
_WAN_PY, str(REPO_ROOT / "scripts" / "sample_appearance_lora.py"),
"--pair", str(pair_dir), "--out", str(out_dir),
"--num-frames", str(num_frames), "--steps", str(steps),
"--cfg-scale", str(cfg_scale), "--seed", str(seed),
"--chunk-frames", str(chunk_frames), "--chain",
]
if offload_text_encoder:
base_cmd.append("--offload-text-encoder")
with (timer.step(step_name, n=num_frames * len(loras)) if timer else _noop()):
for i, lora in enumerate(loras):
cmd = [*base_cmd, "--lora", str(lora)]
if i == 0:
cmd.append("--baseline")
print("running:", " ".join(cmd), flush=True)
result = subprocess.run(cmd, env=env, cwd=REPO_ROOT)
if result.returncode != 0:
raise SystemExit(
f"sample_appearance_lora.py failed (exit {result.returncode}): {lora}"
)
def build_side_by_side(
*, factual_control: Path, factual_output: Path, cf_control: Path, cf_output: Path,
out_path: Path, fps: float, timer=None,
) -> None:
"""One row of four panels: factual control | factual output | cf control | cf output."""
import cv2
import numpy as np
from fpgm.viz.video import H264Writer
order = [
("factual control", factual_control), ("factual output", factual_output),
("counterfactual control", cf_control), ("counterfactual output", cf_output),
]
caps = [cv2.VideoCapture(str(p)) for _, p in order]
w = int(caps[0].get(cv2.CAP_PROP_FRAME_WIDTH))
h = int(caps[0].get(cv2.CAP_PROP_FRAME_HEIGHT))
n = min(int(c.get(cv2.CAP_PROP_FRAME_COUNT)) for c in caps)
with (timer.step("side_by_side", n=n) if timer else _noop()):
writer = H264Writer(out_path, w * 4, h, fps=round(fps), crf=18)
try:
for _ in range(n):
tiles = []
for (label, _path), cap in zip(order, caps, strict=True):
ok, frame_bgr = cap.read()
if not ok:
frame_bgr = np.zeros((h, w, 3), np.uint8)
tile = cv2.resize(frame_bgr, (w, h))[:, :, ::-1].copy() # BGR -> RGB
cv2.putText(tile, label, (10, 26), cv2.FONT_HERSHEY_SIMPLEX,
0.65, (0, 0, 0), 4, cv2.LINE_AA)
cv2.putText(tile, label, (10, 26), cv2.FONT_HERSHEY_SIMPLEX,
0.65, (255, 255, 0), 1, cv2.LINE_AA)
tiles.append(tile)
writer.write(np.hstack(tiles))
finally:
writer.close()
for c in caps:
c.release()
# --------------------------------------------------------------------------- #
# CLI
# --------------------------------------------------------------------------- #
def parse_args() -> argparse.Namespace:
ap = argparse.ArgumentParser(
description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter
)
ap.add_argument("--episode", default=_DEFAULT_UUID)
ap.add_argument("--camera", default=_DEFAULT_SERIAL)
ap.add_argument("--config", type=Path, default=_DEFAULT_CONFIG)
ap.add_argument("--out-dir", type=Path, default=REPO_ROOT / "outputs" / "counterfactual_held")
ap.add_argument("--gpu", type=int, default=None, help="default: auto-pick the freest GPU")
ap.add_argument("--lora-step500", type=Path,
default=REPO_ROOT / "outputs" / "lora_appearance_2k" / "step-500.safetensors")
ap.add_argument("--lora-step2000", type=Path,
default=REPO_ROOT / "outputs" / "lora_appearance_2k" / "step-2000.safetensors")
ap.add_argument("--chunk-frames", type=int, default=81)
ap.add_argument("--steps", type=int, default=30)
ap.add_argument("--cfg-scale", type=float, default=5.0)
ap.add_argument("--seed", type=int, default=0)
ap.add_argument("--offload-text-encoder", action=argparse.BooleanOptionalAction, default=True)
ap.add_argument("--skip-generate", action="store_true",
help="render + verify the controls and stop, for iterating on the "
"rigid-attach logic without paying for a wan-train generation")
ap.add_argument("--skip-side-by-side", action="store_true")
return ap.parse_args()
def main() -> int:
args = parse_args()
os.environ.setdefault("PYOPENGL_PLATFORM", "egl")
from fpgm.utils.timing import StepTimer
timer = StepTimer("counterfactual_held")
render_gpu = args.gpu if args.gpu is not None else 0
os.environ["EGL_DEVICE_ID"] = str(render_gpu)
ctx = _load_context(args.episode, args.camera, args.config, timer=timer)
n = ctx["n_video_frames"]
grasp_frame = find_grasp_frame(ctx["events"], "brick")
print(f"grasp frame (events.json brick.attached first True): {grasp_frame}", flush=True)
with timer.step("build_attachment"):
gripper_cf, t_world_gripper, gripper_real_by_frame, closed_value = (
build_gripper_signal_and_world_gripper(ctx, grasp_frame)
)
t_world_brick_cf, pose_source_cf = build_held_brick_track(ctx, grasp_frame, t_world_gripper)
print(
f"gripper held closed from frame {grasp_frame} onward at value {closed_value:.4f} "
f"(recorded signal's own value at the grasp frame; range was "
f"[{float(ctx['gripper_real'].min()):.4f}, {float(ctx['gripper_real'].max()):.4f}] "
"over the whole trajectory)", flush=True,
)
from fpgm.datagen.export_vace import compose_caption, describe_robot
robot_description = describe_robot(
base_link=ctx["robot"].base_link, link_names=ctx["robot"].link_names
)
caption = compose_caption(
robot_description=robot_description, object_labels=ctx["object_labels"],
task_string=ctx["task_string"],
)
out_dir = args.out_dir
factual_link_poses = render_link_poses_for_frames(ctx, gripper_real_by_frame)
cf_link_poses = render_link_poses_for_frames(ctx, gripper_cf)
factual_samples = render_windows_with_link_poses(
ctx, object_T_world_obj=ctx["object_T_world_obj"],
object_pose_source=ctx["object_pose_source"], link_poses_by_frame=factual_link_poses,
out_root=out_dir / "s8_render" / "factual", caption=caption, timer=timer,
step_name="render_factual",
)
cf_object_T_world_obj = dict(ctx["object_T_world_obj"])
cf_object_T_world_obj["brick"] = t_world_brick_cf
cf_object_pose_source = dict(ctx["object_pose_source"])
cf_object_pose_source["brick"] = pose_source_cf
cf_samples = render_windows_with_link_poses(
ctx, object_T_world_obj=cf_object_T_world_obj, object_pose_source=cf_object_pose_source,
link_poses_by_frame=cf_link_poses, out_root=out_dir / "s8_render" / "counterfactual",
caption=caption, timer=timer, step_name="render_counterfactual",
)
# The S8 export resolution (832x480, DatagenConfig.export_resolution), not the
# native mp4 resolution -- read off the first window's own sample so the
# stitcher never has to duplicate _check_resolution_contract's bucket logic.
export_wh = tuple(factual_samples[0].resolution)
with timer.step("stitch_pairs"):
stitch_pair(
factual_samples, n_video_frames=n, uuid=ctx["uuid"], camera_serial=ctx["camera_serial"],
caption=caption, fps=ctx["mp4_fps"], video_wh=export_wh,
counterfactual_note="none -- measured brick poses, real gripper signal (factual)",
out_dir=out_dir / "pairs" / "factual",
)
stitch_pair(
cf_samples, n_video_frames=n, uuid=ctx["uuid"], camera_serial=ctx["camera_serial"],
caption=caption, fps=ctx["mp4_fps"], video_wh=export_wh,
counterfactual_note=(
f"gripper closes on the brick at frame {grasp_frame} and never opens again "
f"(held at gripper value {closed_value:.4f}); brick rigidly attached to the "
"gripper (T_world_brick(t) = T_world_gripper(t) @ T_gripper_brick) from that "
"frame through the end of the episode. Drawer is UNCHANGED -- it still closes "
"on its own real, measured schedule; see this script's module docstring."
),
out_dir=out_dir / "pairs" / "counterfactual",
)
with timer.step("verify_control_diff"):
diff = control_diff_fractions(
out_dir / "pairs" / "factual" / "control.mp4",
out_dir / "pairs" / "counterfactual" / "control.mp4",
)
print(
f"control diff: mean {diff['mean_diff_fraction']*100:.2f}% max "
f"{diff['max_diff_fraction']*100:.2f}% over {diff['n_frames']} frames "
f"(threshold {diff['pixel_threshold']}/255 per channel)", flush=True,
)
if diff["mean_diff_fraction"] < 0.02:
print(
"WARNING: mean control diff is below 2% -- comparable to the earlier "
"gripper-only-override failure mode (0.29-1.32%). This control may not be "
"depicting the counterfactual; do not proceed to generation conclusions "
"without checking the rendered frames directly.", flush=True,
)
report: dict = {
"episode": ctx["uuid"], "camera_serial": ctx["camera_serial"],
"grasp_frame": grasp_frame, "closed_gripper_value": closed_value,
"caption": caption, "control_diff": diff,
"export_resolution": list(export_wh),
}
if not args.skip_generate:
gpu = args.gpu if args.gpu is not None else _pick_gpu()
print(f"generation GPU: {gpu}", flush=True)
loras = [args.lora_step500, args.lora_step2000]
for branch in ("factual", "counterfactual"):
generate(
out_dir / "pairs" / branch, out_dir / "generation" / branch, gpu=gpu,
num_frames=n, chunk_frames=args.chunk_frames, steps=args.steps,
cfg_scale=args.cfg_scale, seed=args.seed, loras=loras,
offload_text_encoder=args.offload_text_encoder, timer=timer,
step_name=f"generate_{branch}",
)
if not args.skip_side_by_side:
tag = f"{ctx['uuid']}__{ctx['camera_serial']}_f00000_chain{args.chunk_frames}"
checkpoints = (("step-500", args.lora_step500), ("step-2000", args.lora_step2000))
for step_name, lora in checkpoints:
gen_factual = out_dir / "generation" / "factual" / f"{tag}__lora_{lora.stem}.mp4"
gen_cf = out_dir / "generation" / "counterfactual" / f"{tag}__lora_{lora.stem}.mp4"
side_by_side_path = out_dir / f"side_by_side_{step_name}.mp4"
build_side_by_side(
factual_control=out_dir / "pairs" / "factual" / "control.mp4",
factual_output=gen_factual,
cf_control=out_dir / "pairs" / "counterfactual" / "control.mp4",
cf_output=gen_cf,
out_path=side_by_side_path, fps=ctx["mp4_fps"], timer=timer,
)
print(f"wrote {side_by_side_path}", flush=True)
report["timings"] = timer.summary()
(out_dir / "report.json").write_text(json.dumps(report, indent=2, default=str))
print(timer.report(), flush=True)
return 0
if __name__ == "__main__":
raise SystemExit(main())

Xet Storage Details

Size:
44 kB
·
Xet hash:
9aa3121aa6568f24a01f6ed375f5a728d5e11f7f96f597307d9a5067640cc3fe

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