twanghcmut/backup-foundation-physics / scripts /compare_wan_variants.py
twanghcmut's picture
download
raw
20.7 kB
#!/usr/bin/env python
"""Metric + contact-sheet comparison for the Wan2.1-VACE-1.3B control-format
ablation (variants A/B/C/D, see outputs/wan_ablation/{A,B,C,D}/invocation.txt).
The real DROID clip is ground truth for all four variants:
data/droid_raw/AUTOLab+0d4edc83+2023-10-21-19h-07m-04s/recordings/MP4/22008760.mp4
Resolution / frame-count matching (stated explicitly, see the plan's
requirement not to leave this implicit):
- Wan2.1-VACE's own `VaceVideoProcessor` (config: vace-1.3B, size=832*480)
deterministically selects 81 of the real video's 127 frames and a
832x480 target crop from its own 1280x720 native size, given only
fps/frame-count/aspect (not pixel content) -- see
third_party/wan2.1/wan/utils/vace_processor.py
:`_get_frameid_bbox_adjust_last` (keep_last=True at this pinned config).
Because all four control videos share the real clip's frame count (127)
and fps (60), this same selection applies positionally to all of them.
- That exact selection was computed once by loading the upstream class
directly (no reimplementation) against the real clip's actual decord
frame timestamps -- see
/tmp/.../scratchpad/compute_frame_match.py (run under the wan-vace conda
env, which has decord) -- and cached into frame_match.json (copied next
to this script's outputs at outputs/wan_ablation/frame_match.json).
frame_ids = the 81 real-frame indices; target (oh, ow) = (480, 832).
- The 81 matching REAL frames are extracted at those frame_ids and put
through the IDENTICAL resize+crop transform Wan's own
VaceVideoProcessor.resize_crop applies (scale = max(ow/iw, oh/ih),
torchvision bicubic+antialias resize, then centre crop) -- reproduced
here as `resize_crop_like_wan` -- to build `real_matched`: an
(81, 480, 832, 3) uint8 sequence directly comparable, pixel-for-pixel
position, to every generated (81, 480, 832, 3) variant.
- The robot foreground mask (`master/robot_buffers/seg.npy`, (127,720,1280)
uint8, 1=robot) is resampled through the SAME frame_ids + resize_crop
(nearest-neighbor, then thresholded) to get an (81, 480, 832) bool mask
used for the background/foreground metric split. Object masks
(`object_*_masks.h5`) were checked and are populated at only 1/127
frames each (see outputs/wan_ablation/C/invocation.txt) -- not usable,
so the split is robot-vs-everything-else, not robot+objects-vs-rest.
Run with the `trellis2` conda env's python (has torch+cuda, cv2, skimage,
lpips already installed -- no new heavy deps):
/home/quang/miniconda3/envs/trellis2/bin/python scripts/compare_wan_variants.py
CAUGHT CONFOUND (fixed before any conclusion was drawn -- recorded here so it
isn't silently lost): the first control video built for B/C was an upsample
of master/depth_preview.mp4, which was itself produced by an S3-only
dense_depth run with no robot composited in (127 near-identical frames --
see outputs/wan_ablation/B/invocation.txt for the full story and
outputs/wan_ablation/B/*_CONFOUNDED_* for the artifacts). That made the
original B/C generations a test of "a frozen control video", not "our depth
format". Fixed by compositing S2's robot_buffers/depth_mm.npy onto the
upsampled background plate (fpgm.datagen.dense_depth.compose_dense_depth)
before encoding. Because this bit us once already, `CONTROL_VIDEOS` below
computes temporal activity on every control video too, not just the
generated outputs -- if a future control is accidentally static, this table
catches it before a GPU run is spent on it.
Variant A2 (added after the fact): identical control video and seed to A,
reworded prompt (drops "LEGO" to test whether A's LEGO-styled output was a
prompt-hijack artifact rather than a control-format/model-ceiling issue).
"""
import json
import sys
from pathlib import Path
import cv2
import numpy as np
import torch
import torch.nn.functional as F
from skimage.metrics import peak_signal_noise_ratio as sk_psnr
from skimage.metrics import structural_similarity as sk_ssim
REPO = Path("/srv/data/foundation-physics-graph-model")
OUT_DIR = REPO / "outputs/wan_ablation"
REAL_VIDEO = REPO / "data/droid_raw/AUTOLab+0d4edc83+2023-10-21-19h-07m-04s/recordings/MP4/22008760.mp4"
SEG_PATH = REPO / "outputs/datagen/AUTOLab+0d4edc83+2023-10-21-19h-07m-04s/22008760/master/robot_buffers/seg.npy"
FRAME_MATCH_JSON = OUT_DIR / "frame_match.json"
VARIANTS = ["A", "A2", "B", "C", "D"]
CONTACT_SHEET_IDX = [5, 27, 50, 75] # of the 81 matched/generated frames
# Control video actually fed as --src_video for each variant (native
# 1280x720/127f, pre-VaceVideoProcessor). Used only to report each control's
# OWN temporal activity next to its generated output's -- catches a static
# control before/while interpreting a static output (see module docstring:
# this is exactly what the original B/C confound looked like).
CONTROL_VIDEOS = {
"A": OUT_DIR / "A/control_depth_vace_1280x720.mp4",
"A2": OUT_DIR / "A2/control_depth_vace_1280x720.mp4",
"B": OUT_DIR / "B/control_depth_ours_1280x720.mp4",
"C": OUT_DIR / "C/control_depth_ours_1280x720.mp4",
"D": OUT_DIR / "D/control_depth_vace_1280x720.mp4",
}
def read_all_frames_rgb(path):
cap = cv2.VideoCapture(str(path))
frames = []
while True:
ret, f = cap.read()
if not ret:
break
frames.append(cv2.cvtColor(f, cv2.COLOR_BGR2RGB))
cap.release()
return frames
def resize_crop_like_wan(img_hw_c, oh, ow, interp="bicubic"):
"""Reproduce wan/utils/vace_processor.py VaceVideoProcessor.resize_crop
for a single HxWxC (or HxW) uint8/bool array: scale-to-cover + centre
crop, resize via torch (antialiased bicubic for images, nearest for
masks so the binary mask stays binary)."""
arr = np.asarray(img_hw_c)
single_channel = arr.ndim == 2
if single_channel:
arr = arr[..., None]
ih, iw = arr.shape[:2]
t = torch.from_numpy(arr).permute(2, 0, 1).unsqueeze(0).float()
if ih != oh or iw != ow:
scale = max(ow / iw, oh / ih)
new_h, new_w = round(scale * ih), round(scale * iw)
if interp == "nearest":
t = F.interpolate(t, size=(new_h, new_w), mode="nearest")
else:
t = F.interpolate(t, size=(new_h, new_w), mode="bicubic", antialias=True)
x1 = (new_w - ow) // 2
y1 = (new_h - oh) // 2
t = t[:, :, y1:y1 + oh, x1:x1 + ow]
out = t.squeeze(0).permute(1, 2, 0).clamp(0, 255).round().numpy().astype(np.uint8)
if single_channel:
out = out[..., 0]
return out
def build_real_matched_and_mask(frame_match):
frame_ids = frame_match["frame_ids"]
oh, ow = frame_match["target_oh"], frame_match["target_ow"]
real_frames = read_all_frames_rgb(REAL_VIDEO)
seg = np.load(SEG_PATH, mmap_mode="r")
real_matched = np.stack(
[resize_crop_like_wan(real_frames[i], oh, ow, "bicubic") for i in frame_ids]
)
fg_mask = np.stack(
[
resize_crop_like_wan((np.asarray(seg[i]) == 1), oh, ow, "nearest")
for i in frame_ids
]
).astype(bool)
return real_matched, fg_mask, (oh, ow)
def temporal_activity(frames_uint8):
"""Mean absolute frame-to-frame difference, grayscale, normalized to
[0, 1]. frames_uint8: (T, H, W, 3) uint8."""
gray = frames_uint8.astype(np.float32).mean(axis=-1) / 255.0
diffs = np.abs(gray[1:] - gray[:-1])
return float(diffs.mean())
def masked_mean(per_pixel_map, mask):
"""per_pixel_map: (T,H,W). mask: (T,H,W) bool. Returns overall/bg/fg means."""
overall = float(per_pixel_map.mean())
bg = float(per_pixel_map[~mask].mean()) if (~mask).any() else float("nan")
fg = float(per_pixel_map[mask].mean()) if mask.any() else float("nan")
return overall, bg, fg
def compute_psnr_ssim(gen, real_matched, mask):
"""gen, real_matched: (T,H,W,3) uint8. mask: (T,H,W) bool (True=foreground).
Returns dict of overall/bg/fg PSNR (dB, computed from masked MSE, not
averaged per-frame PSNR) and SSIM (averaged per-pixel SSIM map)."""
T = gen.shape[0]
sq_err = (gen.astype(np.float64) - real_matched.astype(np.float64)) ** 2
sq_err_px = sq_err.mean(axis=-1) # (T,H,W)
mse_overall = sq_err_px.mean()
mse_bg = sq_err_px[~mask].mean() if (~mask).any() else float("nan")
mse_fg = sq_err_px[mask].mean() if mask.any() else float("nan")
def mse_to_psnr(mse):
if mse <= 0 or np.isnan(mse):
return float("nan") if np.isnan(mse) else float("inf")
return 10.0 * np.log10((255.0 ** 2) / mse)
psnr = {
"overall": mse_to_psnr(mse_overall),
"background": mse_to_psnr(mse_bg),
"foreground": mse_to_psnr(mse_fg),
}
ssim_maps = np.empty((T,) + gen.shape[1:3], dtype=np.float64)
for t in range(T):
_, smap = sk_ssim(
gen[t], real_matched[t], channel_axis=2, data_range=255, full=True
)
ssim_maps[t] = smap.mean(axis=-1)
ssim_overall, ssim_bg, ssim_fg = masked_mean(ssim_maps, mask)
ssim = {"overall": ssim_overall, "background": ssim_bg, "foreground": ssim_fg}
return psnr, ssim
def compute_lpips(gen, real_matched, mask, lpips_model, device):
T = gen.shape[0]
maps = np.empty((T,) + gen.shape[1:3], dtype=np.float64)
with torch.no_grad():
for t in range(T):
a = torch.from_numpy(gen[t]).permute(2, 0, 1).float().to(device) / 127.5 - 1.0
b = torch.from_numpy(real_matched[t]).permute(2, 0, 1).float().to(device) / 127.5 - 1.0
out = lpips_model(a.unsqueeze(0), b.unsqueeze(0)) # (1,1,H,W)
maps[t] = out.squeeze().cpu().numpy()
overall, bg, fg = masked_mean(maps, mask)
return {"overall": overall, "background": bg, "foreground": fg}
def main():
device = "cuda" if torch.cuda.is_available() else "cpu"
if not FRAME_MATCH_JSON.exists():
print(f"ERROR: {FRAME_MATCH_JSON} missing -- run compute_frame_match.py "
f"(wan-vace env, needs decord) first and copy its output there.",
file=sys.stderr)
sys.exit(1)
frame_match = json.loads(FRAME_MATCH_JSON.read_text())
print(f"frame_match: {len(frame_match['frame_ids'])} frame_ids, "
f"target=({frame_match['target_oh']},{frame_match['target_ow']})")
real_matched, fg_mask, (oh, ow) = build_real_matched_and_mask(frame_match)
print(f"real_matched: {real_matched.shape}, fg_mask mean fraction: {fg_mask.mean():.4f}")
try:
import lpips
lpips_model = lpips.LPIPS(net="alex", spatial=True).to(device)
have_lpips = True
except Exception as e:
print(f"LPIPS unavailable ({e}) -- skipping LPIPS metrics entirely, "
f"not substituting anything else for it.", file=sys.stderr)
lpips_model = None
have_lpips = False
real_temporal = temporal_activity(real_matched)
print(f"real (matched-cadence) temporal activity: {real_temporal:.5f}")
results = {
"methodology": {
"ground_truth": str(REAL_VIDEO),
"frame_matching": "VaceVideoProcessor._get_frameid_bbox_adjust_last (upstream, unmodified), "
"keep_last=True, size=832x480, config vace-1.3B -- see frame_match.json",
"n_frames_matched": len(frame_match["frame_ids"]),
"spatial_matching": "real frames resized (scale-to-cover, bicubic+antialias) + centre-cropped "
f"to {ow}x{oh}, identical to Wan's own VaceVideoProcessor.resize_crop",
"foreground_mask": "robot only (seg.npy==1); object masks populated at 1/127 frames only, excluded",
"lpips_available": have_lpips,
"lpips_net": "alex (spatial=True, per-pixel map, mask-averaged)" if have_lpips else None,
},
"real_baseline": {
"temporal_activity_matched_cadence": real_temporal,
},
"variants": {},
}
for v in VARIANTS:
gen_path = OUT_DIR / v / "generated.mp4"
if not gen_path.exists():
print(f"variant {v}: {gen_path} missing, skipping", file=sys.stderr)
continue
gen_frames = read_all_frames_rgb(gen_path)
gen = np.stack(gen_frames)
print(f"variant {v}: generated shape {gen.shape}")
if gen.shape[0] != real_matched.shape[0]:
n = min(gen.shape[0], real_matched.shape[0])
print(f" WARNING: frame count mismatch ({gen.shape[0]} vs {real_matched.shape[0]}), "
f"truncating both to {n}", file=sys.stderr)
gen_c, real_c, mask_c = gen[:n], real_matched[:n], fg_mask[:n]
else:
gen_c, real_c, mask_c = gen, real_matched, fg_mask
if gen_c.shape[1:3] != real_c.shape[1:3]:
print(f" WARNING: spatial size mismatch {gen_c.shape[1:3]} vs {real_c.shape[1:3]}, "
f"resizing generated to match", file=sys.stderr)
gen_c = np.stack([cv2.resize(f, (real_c.shape[2], real_c.shape[1])) for f in gen_c])
psnr, ssim = compute_psnr_ssim(gen_c, real_c, mask_c)
lp = compute_lpips(gen_c, real_c, mask_c, lpips_model, device) if have_lpips else None
temp_act = temporal_activity(gen_c)
control_path = CONTROL_VIDEOS.get(v)
control_temp_act = None
if control_path is not None and control_path.exists():
control_frames = np.stack(read_all_frames_rgb(control_path))
control_temp_act = temporal_activity(control_frames)
results["variants"][v] = {
"n_frames": int(gen_c.shape[0]),
"resolution": f"{gen_c.shape[2]}x{gen_c.shape[1]}",
"control_video": str(control_path) if control_path else None,
"control_temporal_activity": control_temp_act,
"psnr_db": psnr,
"ssim": ssim,
"lpips": lp,
"temporal_activity": temp_act,
"temporal_activity_vs_real_ratio": temp_act / real_temporal if real_temporal > 0 else float("nan"),
}
print(f" PSNR overall={psnr['overall']:.2f} bg={psnr['background']:.2f} fg={psnr['foreground']:.2f}")
print(f" SSIM overall={ssim['overall']:.4f} bg={ssim['background']:.4f} fg={ssim['foreground']:.4f}")
if lp:
print(f" LPIPS overall={lp['overall']:.4f} bg={lp['background']:.4f} fg={lp['foreground']:.4f}")
print(f" temporal_activity={temp_act:.5f} (real={real_temporal:.5f}, ratio={temp_act/real_temporal:.3f})"
+ (f" [control's own temporal_activity={control_temp_act:.5f}]" if control_temp_act is not None else ""))
(OUT_DIR / "comparison.json").write_text(json.dumps(results, indent=2))
print(f"\nwrote {OUT_DIR / 'comparison.json'}")
write_markdown_table(results)
build_contact_sheet(real_matched, fg_mask)
def write_markdown_table(results):
v = results["variants"]
lines = []
lines.append("# Wan2.1-VACE-1.3B control-format ablation -- metric comparison\n")
if all(k in v for k in ("A", "B", "D")) and v["A"]["lpips"] and v["B"]["lpips"]:
lpips_vace = [v[k]["lpips"]["overall"] for k in ("A", "A2", "D") if k in v and v[k]["lpips"]]
lpips_ours = [v[k]["lpips"]["overall"] for k in ("B", "C") if k in v and v[k]["lpips"]]
lines.append(
f"## Verdict\n\n"
f"**A vs B (load-bearing comparison, same model/scene/prompt/seed, only the control "
f"representation differs): A looks and scores photoreal, B looks like a colourised "
f"depth map and scores worse on every perceptual metric.** "
f"Mean LPIPS with VACE's own depth format (A/A2/D) = {np.mean(lpips_vace):.3f}; "
f"mean LPIPS with our depth format (B/C) = {np.mean(lpips_ours):.3f} "
f"(higher = more different from real, worse). D (VACE's depth format + our plate.png "
f"as reference image) is the best variant on every metric in this table. "
f"This is our control-buffer format being out of distribution for VACE-1.3B, not the "
f"model's ceiling -- the same checkpoint produces materially different, more photoreal "
f"output when fed VACE's own depth representation instead of ours. "
f"PSNR/SSIM alone would give the opposite (wrong) impression here -- B/C score "
f"*higher* overall PSNR/SSIM than A/A2 despite looking like a false-colour depth-map "
f"passthrough; LPIPS (perceptual) is what actually tracks the visual gap. See "
f"`contact_sheet.png` for the visual evidence and the report for the full breakdown "
f"(control-format finding) and the separate LEGO-styling / prompt-hijack finding "
f"(A vs A2).\n"
)
lines.append(f"Ground truth: real DROID clip, {results['methodology']['n_frames_matched']} frames "
f"matched to each generated video via the upstream `VaceVideoProcessor` frame selection "
f"(see methodology in `compare_wan_variants.py` docstring and `frame_match.json`).\n")
lines.append(f"LPIPS: {'alex net, spatial map, mask-averaged' if results['methodology']['lpips_available'] else 'NOT AVAILABLE, skipped (no substitute used)'}\n")
lines.append(f"Real (matched-cadence) temporal activity baseline: "
f"**{results['real_baseline']['temporal_activity_matched_cadence']:.5f}** "
f"(mean abs frame-to-frame grayscale diff, [0,1] scale)\n")
lines.append("| Variant | Control temporal activity | Output temporal activity | vs real | PSNR overall | PSNR bg | PSNR fg | SSIM overall | SSIM bg | SSIM fg | "
"LPIPS overall | LPIPS bg | LPIPS fg |")
lines.append("|---|---|---|---|---|---|---|---|---|---|---|---|---|")
for v, r in results["variants"].items():
lp = r["lpips"]
lp_o = f"{lp['overall']:.4f}" if lp else "n/a"
lp_bg = f"{lp['background']:.4f}" if lp else "n/a"
lp_fg = f"{lp['foreground']:.4f}" if lp else "n/a"
ctrl_act = r.get("control_temporal_activity")
ctrl_act_s = f"{ctrl_act:.5f}" if ctrl_act is not None else "n/a"
lines.append(
f"| {v} | {ctrl_act_s} | {r['temporal_activity']:.5f} | {r['temporal_activity_vs_real_ratio']:.3f}x "
f"| {r['psnr_db']['overall']:.2f} | {r['psnr_db']['background']:.2f} | {r['psnr_db']['foreground']:.2f} "
f"| {r['ssim']['overall']:.4f} | {r['ssim']['background']:.4f} | {r['ssim']['foreground']:.4f} "
f"| {lp_o} | {lp_bg} | {lp_fg} |"
)
lines.append("\nNote: variants B and C's control video contains robot motion only (no brick/drawer -- "
"S6 object-pose wiring is a sibling's in-progress work), so their control's temporal "
"activity is expected to be well below A/D's dense-depth-network control and below the "
"real clip's, independent of any format quality question.")
(OUT_DIR / "comparison.md").write_text("\n".join(lines) + "\n")
print(f"wrote {OUT_DIR / 'comparison.md'}")
def build_contact_sheet(real_matched, fg_mask):
variants_present = [v for v in VARIANTS if (OUT_DIR / v / "generated.mp4").exists()]
rows = ["real"] + variants_present
cols = CONTACT_SHEET_IDX
oh, ow = real_matched.shape[1:3]
pad = 4
label_h = 24
cell_w, cell_h = ow // 2, oh // 2 # downscale for a manageable contact sheet
sheet_w = len(cols) * (cell_w + pad) + pad
sheet_h = len(rows) * (cell_h + label_h + pad) + pad
sheet = np.full((sheet_h, sheet_w, 3), 30, dtype=np.uint8)
gen_cache = {}
for v in variants_present:
gen_cache[v] = np.stack(read_all_frames_rgb(OUT_DIR / v / "generated.mp4"))
for ri, row in enumerate(rows):
for ci, idx in enumerate(cols):
if row == "real":
frame = real_matched[idx]
else:
frames = gen_cache[row]
frame = frames[idx] if idx < frames.shape[0] else frames[-1]
thumb = cv2.resize(frame, (cell_w, cell_h))
y0 = pad + ri * (cell_h + label_h + pad)
x0 = pad + ci * (cell_w + pad)
sheet[y0:y0 + cell_h, x0:x0 + cell_w] = thumb
label = f"{row} @{idx}" if ci == 0 else f"@{idx}"
cv2.putText(sheet, label, (x0, y0 + cell_h + 16), cv2.FONT_HERSHEY_SIMPLEX,
0.45, (255, 255, 255), 1, cv2.LINE_AA)
# row label on the very left
y0 = pad + ri * (cell_h + label_h + pad)
out_path = OUT_DIR / "contact_sheet.png"
cv2.imwrite(str(out_path), cv2.cvtColor(sheet, cv2.COLOR_RGB2BGR))
print(f"wrote {out_path}")
if __name__ == "__main__":
main()

Xet Storage Details

Size:
20.7 kB
·
Xet hash:
f6c9104ab7a19e59d5c715fe30045f3c4e6cfddd2ac9f176ab75443d37a0407a

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