Buckets:
| """``torch.utils.data.Dataset`` over the appearance pairs, for the VACE fine-tune. | |
| This is the second, much thinner training input alongside | |
| :class:`~fpgm.training.dataset.WindowBundleDataset`, and the two exist for | |
| different failure modes: | |
| * ``WindowBundleDataset`` feeds the datagen bundles -- false-colour depth/seg/ | |
| normal buffers composited into a control. Those are what the *deployed* | |
| generator will be conditioned on, and they need every lossless-decode | |
| guarantee ``bundle_io`` provides. | |
| * This class feeds photograph-like controls: a real frame with a URDF-rendered | |
| Franka composited over the real arm (``scripts/appearance_control.py``). Its | |
| only job is to teach the model what a Franka *looks like*, which VACE-1.3B | |
| measurably does not know zero-shot -- handed 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. | |
| Because the control here is an ordinary H.264 photograph, none of the lossless | |
| machinery applies: there are no integer ids to corrupt, so a plain OpenCV decode | |
| is not a shortcut, it is the whole contract. | |
| The emitted dict has exactly the keys ``WanTrainingModule`` consumes, identical | |
| to ``WindowBundleDataset.__getitem__`` -- ``video`` / ``prompt`` / ``vace_video`` | |
| / ``vace_reference_image`` -- so either dataset can be handed to the same | |
| trainer without a branch downstream. | |
| """ | |
| from __future__ import annotations | |
| import json | |
| from dataclasses import dataclass | |
| from pathlib import Path | |
| import cv2 | |
| import numpy as np | |
| import torch | |
| from PIL import Image | |
| from fpgm.types import DataError | |
| #: VACE's training resolution. Both videos and the reference go through the same | |
| #: cover-crop to it, so a control pixel and its target pixel stay the same pixel. | |
| DEFAULT_RESOLUTION = (832, 480) | |
| #: Wan's VAE has temporal stride 4 and encodes a leading frame on its own, so a | |
| #: window length must be ``4k+1``. 81 is the length every datagen window already | |
| #: uses; keeping it identical means a mixed run has one frame count, not two. | |
| WINDOW_FRAMES = 81 | |
| class AppearancePairError(DataError): | |
| """A pair directory is unreadable or its two videos disagree.""" | |
| class AppearanceWindow: | |
| """One ``(control, target)`` window: a pair directory plus a start frame.""" | |
| pair_dir: Path | |
| start: int | |
| n_frames: int | |
| caption: str | |
| uuid: str | |
| camera_serial: str | |
| def name(self) -> str: | |
| return f"{self.uuid}/{self.camera_serial}/f{self.start:05d}" | |
| def _cover_crop(image: np.ndarray, out_w: int, out_h: int) -> np.ndarray: | |
| """Scale-to-cover + centre-crop, the same transform ``export_vace._CoverCrop`` applies. | |
| Reimplemented rather than imported because that class is private to the | |
| datagen exporter and carries an intrinsics method this path has no camera | |
| for; the pixel arithmetic is deliberately identical so a mixed training run | |
| sees one framing convention, not two. | |
| """ | |
| in_h, in_w = image.shape[:2] | |
| if (in_w, in_h) == (out_w, out_h): | |
| return image # the pairs are written at the training resolution | |
| scale = max(out_w / in_w, out_h / in_h) | |
| rw, rh = int(round(in_w * scale)), int(round(in_h * scale)) | |
| resized = cv2.resize( | |
| image, (rw, rh), | |
| interpolation=cv2.INTER_AREA if scale < 1.0 else cv2.INTER_LINEAR, | |
| ) | |
| x0, y0 = int(round((rw - out_w) / 2.0)), int(round((rh - out_h) / 2.0)) | |
| return resized[y0:y0 + out_h, x0:x0 + out_w] | |
| def _read_window(path: Path, start: int, n: int, out_w: int, out_h: int) -> np.ndarray: | |
| """Decode ``n`` frames from ``start`` as cover-cropped RGB, ``(n, out_h, out_w, 3)``. | |
| Seeks with ``CAP_PROP_POS_FRAMES`` and then reads sequentially. The seek is | |
| only ever to a window start, so a keyframe-inexact seek costs at most a few | |
| frames of position error on a clip that was written at crf 14 with default | |
| GOP -- and both videos of a pair are seeked identically, so any such error is | |
| common-mode and does not desynchronise control from target. | |
| """ | |
| cap = cv2.VideoCapture(str(path)) | |
| if not cap.isOpened(): | |
| raise AppearancePairError(f"could not open {path}") | |
| try: | |
| cap.set(cv2.CAP_PROP_POS_FRAMES, start) | |
| frames = [] | |
| for _ in range(n): | |
| ok, bgr = cap.read() | |
| if not ok: | |
| break | |
| frames.append(_cover_crop(bgr, out_w, out_h)[:, :, ::-1]) | |
| finally: | |
| cap.release() | |
| if len(frames) != n: | |
| raise AppearancePairError( | |
| f"{path}: wanted {n} frames from {start}, decoded {len(frames)}" | |
| ) | |
| return np.stack(frames) | |
| def discover_windows( | |
| root: Path, | |
| window_frames: int = WINDOW_FRAMES, | |
| stride: int | None = None, | |
| min_occlusion_ok: float = 0.25, | |
| ) -> list[AppearanceWindow]: | |
| """Every window of every pair directory under ``root``. | |
| Args: | |
| root: ``outputs/appearance_pairs`` -- one subdirectory per (episode, | |
| camera), each holding ``control.mp4``, ``target.mp4``, ``meta.json``. | |
| window_frames: frames per window; must stay ``4k+1``. | |
| stride: window start step. Defaults to ``window_frames`` (no overlap). | |
| A smaller stride multiplies the sample count from the same clips, | |
| at the cost of the extra windows being highly correlated. | |
| min_occlusion_ok: drop a clip whose ``occluded_px_fraction`` exceeds | |
| this. That number is the share of rendered robot pixels the depth | |
| test suppressed; a clip far above the ~1% typical value means the | |
| MoGe/render scale fit went wrong and the control has holes punched | |
| in the arm, which is the one defect that would actively teach the | |
| wrong appearance. This is a *diagnostic* filter on a measured | |
| quantity, not one of the datagen geometry gates -- those are about | |
| object pose and do not apply here. | |
| Raises: | |
| AppearancePairError: if ``root`` holds no usable pair. | |
| """ | |
| stride = stride or window_frames | |
| if (window_frames - 1) % 4: | |
| raise ValueError(f"window_frames must be 4k+1, got {window_frames}") | |
| windows: list[AppearanceWindow] = [] | |
| skipped: list[str] = [] | |
| for meta_path in sorted(root.glob("*/meta.json")): | |
| meta = json.loads(meta_path.read_text()) | |
| n = int(meta["n_frames"]) | |
| if n < window_frames: | |
| skipped.append(f"{meta_path.parent.name}: {n} frames < {window_frames}") | |
| continue | |
| if float(meta.get("occluded_px_fraction", 0.0)) > min_occlusion_ok: | |
| skipped.append( | |
| f"{meta_path.parent.name}: occluded_px_fraction " | |
| f"{meta['occluded_px_fraction']:.3f} > {min_occlusion_ok}" | |
| ) | |
| continue | |
| caption = (meta.get("caption") or "").strip() | |
| for start in range(0, n - window_frames + 1, stride): | |
| windows.append(AppearanceWindow( | |
| pair_dir=meta_path.parent, start=start, n_frames=window_frames, | |
| caption=caption, uuid=meta["uuid"], | |
| camera_serial=str(meta["camera_serial"]), | |
| )) | |
| if not windows: | |
| raise AppearancePairError( | |
| f"no usable appearance pairs under {root}" | |
| + (f" ({len(skipped)} skipped: {skipped[:3]})" if skipped else "") | |
| ) | |
| return windows | |
| class AppearancePairDataset(torch.utils.data.Dataset): | |
| """One item = one 81-frame ``(control, target)`` window. | |
| Batch size is effectively 1, matching ``WindowBundleDataset``: DiffSynth's | |
| training loop collates with ``lambda x: x[0]``. | |
| """ | |
| def __init__( | |
| self, | |
| windows: list[AppearanceWindow], | |
| resolution: tuple[int, int] = DEFAULT_RESOLUTION, | |
| reference: str = "target_frame0", | |
| ) -> None: | |
| """ | |
| Args: | |
| windows: from :func:`discover_windows`. | |
| resolution: ``(width, height)`` both videos are cover-cropped to. | |
| reference: what to hand VACE as ``vace_reference_image``. | |
| ``"target_frame0"`` -- the episode's real first frame, i.e. what | |
| the zero-shot probe used. It is decoded from ``target.mp4`` | |
| rather than a stored PNG: a 0.9 MB per-clip copy of a frame that | |
| is already in the video is 25% of the clip's whole footprint for | |
| nothing. The real robot is *in* that frame, so | |
| the model can learn to copy its appearance from the reference | |
| rather than into its own weights. That is a real train/deploy | |
| mismatch: at datagen inference time the reference is | |
| ``ref_plate.png``, a background plate with no robot in it. | |
| ``"window_frame0"`` -- the window's own first *control* frame | |
| (CAD robot, real background), which removes the real robot from | |
| the reference entirely and forces the appearance into the | |
| weights, at the cost of no longer matching the probe. | |
| Neither is silently "correct"; the mismatch above is the thing to | |
| measure, so both are available and the choice is recorded per item | |
| in ``_reference``. | |
| """ | |
| if not windows: | |
| raise AppearancePairError("AppearancePairDataset built with zero windows") | |
| if reference not in ("target_frame0", "window_frame0"): | |
| raise ValueError(f"unknown reference mode {reference!r}") | |
| self.windows = list(windows) | |
| self.resolution = resolution | |
| self.reference = reference | |
| def __len__(self) -> int: | |
| return len(self.windows) | |
| def __getitem__(self, index: int) -> dict: | |
| win = self.windows[index % len(self.windows)] | |
| out_w, out_h = self.resolution | |
| target = _read_window(win.pair_dir / "target.mp4", win.start, win.n_frames, | |
| out_w, out_h) | |
| control = _read_window(win.pair_dir / "control.mp4", win.start, win.n_frames, | |
| out_w, out_h) | |
| if self.reference == "window_frame0": | |
| ref = control[0] | |
| else: | |
| ref = _read_window(win.pair_dir / "target.mp4", 0, 1, out_w, out_h)[0] | |
| return { | |
| "video": [Image.fromarray(f) for f in target], | |
| "prompt": win.caption, | |
| "vace_video": [Image.fromarray(f) for f in control], | |
| "vace_reference_image": [Image.fromarray(np.ascontiguousarray(ref))], | |
| "_window_name": win.name, | |
| "_reference": self.reference, | |
| } | |
Xet Storage Details
- Size:
- 10.6 kB
- Xet hash:
- bc0cd2e754e2e473cd3b2c837749ad544c62484c095d30acd1635d1d1657522a
·
Xet efficiently stores files, intelligently splitting them into unique chunks and accelerating uploads and downloads. More info.