Files
hao-ai-lab-FastVideo/data_pipeline/eval_sparse_track_epe.py
T
shaoxiongduan ac919b1c87 [feat] WanTrack: OpenVid preprocess + Wan2.2 synthetic-gen pipeline, training configs, first-frame saturation fix
- data_pipeline: SAM+CoTracker preprocessing, sharded Slurm launchers, OpenVid download/filter,
  and a data-parallel Wan2.2-T2V-A14B synthetic video generator with the first-frame
  over-brightness fix (over-generate 129 -> drop first 8 -> keep 121).
- examples/train: OpenVid bidir/stage2/stage3 launchers + finetune YAMLs (sparse, merged,
  merged_bias, stage2v2 hi-lr, stage3A/B/C).
- research_log: first-frame saturation root-cause writeup (DiT position-0 latent, not VAE decode).
- fastvideo: trackwan model / track-encoder / preprocessing updates, dcp_to_diffusers export, FA4.
2026-07-14 08:58:22 +00:00

417 lines
19 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
# SPDX-License-Identifier: Apache-2.0
"""Evaluate super-sparse track adherence via CoTracker EPE.
For each val clip, we build the exact adversarial case the user described:
* Pick ONE active foreground trace from the GT parquet — the highest-motion
foreground track (object_ids >= 0, visible in frame 0).
* Pick N static background anchors — random background tracks (object_ids == -1)
with position frozen at frame-0 (visibility=1 across all frames).
* Generate video conditioned on (active + anchors) — this is what training
was supposed to teach the model to handle.
* Also generate an ablation with track_points=None (unconditional motion).
* Run CoTracker on both generated videos, initialised at frame 0 with the
conditioning points. Compare CoTracker-extracted tracks against the GT
trace to compute End-Point-Error in pixel space.
Metrics logged per clip and aggregated to wandb:
epe_active_full EPE(GT_active, CoTracker(gen_full))
epe_active_notrack EPE(GT_active, CoTracker(gen_no_track))
epe_bg EPE(static_anchor, CoTracker(gen_full)) — should be small
DELTA = epe_active_notrack - epe_active_full — positive means training helped.
Usage:
srun --overlap --jobid=529 --ntasks=1 -w hpc-rack-2-13 --chdir=$PWD bash -lc '
source .venv/bin/activate
export HOME=/mnt/lustre/vlm-s4duan HF_HOME=/mnt/lustre/vlm-s4duan/.hf \
TORCH_HOME=/mnt/lustre/vlm-s4duan/.torch MPLCONFIGDIR=/mnt/lustre/vlm-s4duan/.mpl \
TRITON_CACHE_DIR=/tmp/triton_eval TOKENIZERS_PARALLELISM=false NCCL_CUMEM_ENABLE=0 \
PYTHONPATH=$PWD TRACKWAN_TRACK_BIAS=1 CUDA_VISIBLE_DEVICES=2 \
WANDB_API_KEY=<key> WANDB_MODE=online
python data_pipeline/eval_sparse_track_epe.py \
--model-dir /mnt/lustre/vlm-s4duan/exports/merged_bias_ckpt4800 \
--yaml examples/train/scenario/worldmodel/finetune_wantrack_openvid_sparse_1p3b_merged_bias.yaml \
--data-path /mnt/lustre/vlm-s4duan/openvid_1m/combined_parquet_dataset \
--num-clips 5 --steps 30 --w-motion 1.5 \
--wandb-run-name eval_mb4800_sparse
'
"""
from __future__ import annotations
import argparse
import glob
import json
import os
import sys
import time
from pathlib import Path
import numpy as np
import torch
# make trackwan_infer importable
sys.path.insert(0, os.path.join(os.path.dirname(__file__)))
# ==========================================================================
# CoTracker
# ==========================================================================
def load_cotracker(device: str) -> torch.nn.Module:
"""Load CoTracker v3 offline. Uses the warm hub cache under $TORCH_HOME/hub."""
hub_dir = Path(torch.hub.get_dir())
local = hub_dir / "facebookresearch_co-tracker_main"
if local.exists():
model = torch.hub.load(str(local), "cotracker3_offline", source="local", trust_repo=True)
else:
model = torch.hub.load("facebookresearch/co-tracker", "cotracker3_offline", trust_repo=True)
return model.to(device).eval()
@torch.no_grad()
def cotracker_query(cotracker: torch.nn.Module, video: np.ndarray, queries_xy: np.ndarray,
device: str) -> tuple[np.ndarray, np.ndarray]:
"""Track `queries_xy` (N, 2 pixel coords) forward through `video` [T,H,W,3] uint8.
Returns tracks [T,N,2] (pixel coords) and visibility [T,N] (bool).
"""
T, H, W, C = video.shape
v = torch.from_numpy(video).permute(0, 3, 1, 2).float()[None].to(device) # [1,T,3,H,W]
N = queries_xy.shape[0]
q = np.zeros((1, N, 3), dtype=np.float32)
q[0, :, 0] = 0 # start-frame index
q[0, :, 1] = queries_xy[:, 0] # x
q[0, :, 2] = queries_xy[:, 1] # y
tracks, visibility = cotracker(v, queries=torch.from_numpy(q).to(device))
return tracks[0].cpu().numpy(), visibility[0].cpu().numpy()
# ==========================================================================
# Sparse-conditioning sampler
# ==========================================================================
def pick_sparse_conditioning(row: dict, *, num_active: int, num_bg: int,
seed: int) -> dict:
"""From a preprocessed parquet row, pick the sparse case: `num_active` moving fg
traces (highest-displacement fg tracks visible in frame 0) + `num_bg` static bg
anchors (random bg tracks, position frozen at frame-0).
Everything is normalised to [0,1] to match training. Pixel-space is only used at
the metric-computation step.
"""
tp = row["track_points"] # [T,N,2] normalised
tv = row["track_visibility"] # [T,N]
oid = row["object_ids"] # [N] int, -1 for bg
T, N, _ = tp.shape
rng = np.random.RandomState(seed)
fg = np.where((oid >= 0) & (tv[0] > 0.5))[0]
bg = np.where((oid == -1) & (tv[0] > 0.5))[0]
if fg.size == 0:
raise RuntimeError("no visible foreground tracks in frame 0")
# displacement of each fg over the full clip (only over visible frames)
disp = np.linalg.norm(tp[-1, fg] - tp[0, fg], axis=-1)
active_idx = fg[np.argsort(-disp)[:num_active]] # top-`num_active` moving fg
n_bg = min(num_bg, bg.size)
bg_idx = rng.choice(bg, size=n_bg, replace=False) if n_bg > 0 else np.zeros((0, ), dtype=int)
active_gt = tp[:, active_idx] # [T, num_active, 2] — GT motion
active_vis = tv[:, active_idx]
bg_static_pos = np.broadcast_to(tp[0:1, bg_idx], (T, n_bg, 2)).copy() # position frozen at f0
bg_vis = np.ones((T, n_bg), dtype=np.float32)
cond_tracks = np.concatenate([active_gt, bg_static_pos], axis=1) # [T, num_active+n_bg, 2]
cond_vis = np.concatenate([active_vis, bg_vis], axis=1)
return {
"active_idx": active_idx,
"active_gt": active_gt, # [T,num_active,2] normalised
"active_vis": active_vis,
"bg_static_pos": bg_static_pos, # [T,n_bg,2] normalised (const in t)
"cond_tracks": cond_tracks, # [T, num_active+n_bg, 2] normalised
"cond_vis": cond_vis,
"n_active": len(active_idx),
"n_bg": n_bg,
}
# ==========================================================================
# Metrics
# ==========================================================================
def epe_norm_to_px(pred_norm: np.ndarray, gt_norm: np.ndarray, vis: np.ndarray,
res_h: int, res_w: int) -> float:
"""L2 distance between predicted/GT normalised tracks, evaluated in pixel space,
averaged only over visible frames × visible points."""
diff = (pred_norm - gt_norm) * np.array([res_w, res_h])[None, None, :]
l2 = np.linalg.norm(diff, axis=-1) # [T, N]
mask = vis > 0.5
if not mask.any():
return float("nan")
return float(l2[mask].mean())
def epe_px(pred_px: np.ndarray, gt_px: np.ndarray, vis: np.ndarray) -> float:
"""L2 in pixel space — both inputs already in pixels."""
l2 = np.linalg.norm(pred_px - gt_px, axis=-1)
mask = vis > 0.5
if not mask.any():
return float("nan")
return float(l2[mask].mean())
# ==========================================================================
# Trace overlay for wandb visualisation
# ==========================================================================
def overlay_tracks(video: np.ndarray, tracks_norm: np.ndarray, vis: np.ndarray,
colors: list[tuple[int, int, int]], radius: int = 3,
tail: int = 12) -> np.ndarray:
"""Draw normalised tracks on a video [T,H,W,3] uint8. Small tail + current dot."""
from PIL import Image, ImageDraw
T, H, W, C = video.shape
N = tracks_norm.shape[1]
out = []
for t in range(T):
img = Image.fromarray(np.ascontiguousarray(video[t]))
dr = ImageDraw.Draw(img)
t0 = max(0, t - tail)
for i in range(N):
col = colors[i % len(colors)]
# tail
if t - t0 >= 1:
pts = [(float(tracks_norm[k, i, 0] * W), float(tracks_norm[k, i, 1] * H))
for k in range(t0, t + 1)]
dr.line(pts, fill=col, width=1)
if vis[t, i] > 0.5:
x = float(tracks_norm[t, i, 0] * W)
y = float(tracks_norm[t, i, 1] * H)
dr.ellipse([x - radius, y - radius, x + radius, y + radius], fill=col)
out.append(np.asarray(img))
return np.stack(out)
# ==========================================================================
# Parquet loader (small subset — avoid loading the whole 259k row set)
# ==========================================================================
def load_first_n_from_parquet(data_path: str, n: int, text_len: int) -> list:
import pyarrow.parquet as pq
from fastvideo.dataset.dataloader.schema import pyarrow_schema_i2v_track
from fastvideo.dataset.utils import collate_rows_from_parquet_schema
files = sorted(glob.glob(os.path.join(data_path, "**", "*.parquet"), recursive=True))
if not files:
raise FileNotFoundError(data_path)
rows: list = []
for f in files:
rows.extend(pq.read_table(f).to_pylist())
if len(rows) >= n:
break
sel = rows[:n]
batch = collate_rows_from_parquet_schema(sel, pyarrow_schema_i2v_track,
text_padding_length=int(text_len), cfg_rate=0.0)
infos = batch.get("info_list") or [{} for _ in sel]
out = []
for i in range(len(sel)):
out.append({
"text_embedding": batch["text_embedding"][i:i + 1].clone(),
"text_attention_mask": batch["text_attention_mask"][i:i + 1].clone(),
"first_frame_latent": batch["first_frame_latent"][i:i + 1].clone(),
"clip_feature": batch["clip_feature"][i:i + 1].clone(),
"vae_latent": batch["vae_latent"][i:i + 1].clone(),
"track_points": batch["track_points"][i].numpy(), # [T,N,2] normalised
"track_visibility": batch["track_visibility"][i].numpy(), # [T,N]
"object_ids": batch["object_ids"][i].numpy(), # [N]
"caption": str(infos[i].get("caption", "") if i < len(infos) else ""),
})
return out
# ==========================================================================
# Main
# ==========================================================================
def main() -> None:
p = argparse.ArgumentParser()
p.add_argument("--model-dir", required=True)
p.add_argument("--yaml", required=True)
p.add_argument("--data-path", required=True)
p.add_argument("--num-clips", type=int, default=5)
p.add_argument("--num-active", type=int, default=1)
p.add_argument("--num-bg", type=int, default=20)
p.add_argument("--steps", type=int, default=30)
p.add_argument("--w-motion", type=float, default=1.5)
p.add_argument("--seed", type=int, default=1234)
p.add_argument("--fps", type=int, default=24)
p.add_argument("--wandb-project", default="wantrack-bidir")
p.add_argument("--wandb-run-name", required=True)
p.add_argument("--out-dir", default="/mnt/lustre/vlm-s4duan/eval_sparse_epe")
args = p.parse_args()
Path(args.out_dir).mkdir(parents=True, exist_ok=True)
import wandb
import imageio.v2 as imageio
import trackwan_infer as twi
print(f"[eval] loading model {args.model_dir}", flush=True)
model, tc = twi.load_trackwan(args.model_dir, args.yaml)
text_len = int(tc.data.text_padding_length) if hasattr(tc.data, "text_padding_length") else 256
print(f"[eval] loading {args.num_clips} parquet clips", flush=True)
samples = load_first_n_from_parquet(args.data_path, args.num_clips, text_len)
print("[eval] loading CoTracker", flush=True)
device = model.device
cotracker = load_cotracker(device.type + ":" + str(device.index) if device.index is not None else device.type)
res_h = int(tc.data.num_height)
res_w = int(tc.data.num_width)
palette = [(255, 60, 60), (60, 255, 60), (60, 90, 255), (255, 200, 60), (200, 60, 255), (60, 255, 220)]
run = wandb.init(project=args.wandb_project, name=args.wandb_run_name,
config={"model_dir": args.model_dir, "num_clips": args.num_clips,
"num_active": args.num_active, "num_bg": args.num_bg,
"steps": args.steps, "w_motion": args.w_motion, "res": [res_h, res_w]})
per_clip: list = []
for idx, s in enumerate(samples):
try:
sc = pick_sparse_conditioning(s, num_active=args.num_active, num_bg=args.num_bg,
seed=args.seed + idx)
except RuntimeError as exc:
print(f"[eval] clip {idx}: {exc}; skipping", flush=True)
continue
# ── Generate two videos: with sparse tracks (motion CFG) + without any tracks ──
tp_t = torch.from_numpy(sc["cond_tracks"])[None].float() # [1,T,N,2]
tv_t = torch.from_numpy(sc["cond_vis"])[None].float() # [1,T,N]
seed = args.seed + idx
common = dict(first_frame_latent=s["first_frame_latent"],
text_embedding=s["text_embedding"],
text_attention_mask=s["text_attention_mask"],
clip_feature=s["clip_feature"],
num_steps=args.steps, seed=seed)
print(f"[eval] clip {idx}: generating full ({sc['n_active']} active + {sc['n_bg']} anchors)", flush=True)
t0 = time.time()
lat_full = twi.generate(model, track_points=tp_t, track_visibility=tv_t,
guidance_scale=args.w_motion, **common)
gen_full = twi.decode_to_pixels(model, lat_full) # [T,H,W,3] uint8
t_full = time.time() - t0
print(f"[eval] clip {idx}: full took {t_full:.1f}s", flush=True)
print(f"[eval] clip {idx}: generating no-track", flush=True)
t0 = time.time()
lat_no = twi.generate(model, track_points=None, track_visibility=None,
guidance_scale=1.0, **common)
gen_no = twi.decode_to_pixels(model, lat_no)
t_no = time.time() - t0
print(f"[eval] clip {idx}: no-track took {t_no:.1f}s", flush=True)
T, H, W, _ = gen_full.shape
# ── CoTracker on both generations, queried at frame-0 with same points ──
query_norm = sc["cond_tracks"][0] # [N_total, 2] in [0,1]
queries_px = query_norm * np.array([W, H])[None] # pixel space
print(f"[eval] clip {idx}: CoTracker on full", flush=True)
tk_full_px, tk_full_vis = cotracker_query(cotracker, gen_full, queries_px, device=device.type)
print(f"[eval] clip {idx}: CoTracker on no-track", flush=True)
tk_no_px, tk_no_vis = cotracker_query(cotracker, gen_no, queries_px, device=device.type)
# GT in pixel space, at the SAME resolution as the generation
gt_full_px = sc["cond_tracks"] * np.array([W, H])[None, None, :] # [T,N_total,2]
active_slice = slice(0, sc["n_active"])
bg_slice = slice(sc["n_active"], sc["n_active"] + sc["n_bg"])
# Active EPE: CoTracker(gen) vs the GT trace we specified.
# Filter by GT visibility (active_vis) AND CoTracker's own visibility.
active_vis_gt = sc["active_vis"] # [T,n_active]
both_vis_full = (active_vis_gt > 0.5) & (tk_full_vis[:, active_slice] > 0.5)
both_vis_no = (active_vis_gt > 0.5) & (tk_no_vis[:, active_slice] > 0.5)
epe_active_full = epe_px(tk_full_px[:, active_slice],
gt_full_px[:, active_slice],
both_vis_full.astype(np.float32))
epe_active_no = epe_px(tk_no_px[:, active_slice],
gt_full_px[:, active_slice],
both_vis_no.astype(np.float32))
# BG EPE (only for full): CoTracker(gen_full) vs static anchor position
bg_static_px = sc["bg_static_pos"] * np.array([W, H])[None, None, :]
bg_vis_mask = (tk_full_vis[:, bg_slice] > 0.5).astype(np.float32)
epe_bg = epe_px(tk_full_px[:, bg_slice], bg_static_px, bg_vis_mask) if sc["n_bg"] > 0 else float("nan")
delta = epe_active_no - epe_active_full # positive => tracks helped
# ── Save + log videos ──
overlay_full = overlay_tracks(gen_full, sc["cond_tracks"], sc["cond_vis"], palette)
overlay_no = overlay_tracks(gen_no, sc["cond_tracks"], sc["cond_vis"], palette)
# Also show the CoTracker-extracted tracks over the full generation — this is
# what the model *actually* produced motion-wise.
cotr_tracks_norm = tk_full_px / np.array([W, H])[None, None, :]
overlay_cotr = overlay_tracks(gen_full, cotr_tracks_norm, tk_full_vis.astype(np.float32), palette)
clip_dir = Path(args.out_dir) / f"clip_{idx:03d}"
clip_dir.mkdir(parents=True, exist_ok=True)
p_full = clip_dir / "gen_full_overlay.mp4"
p_no = clip_dir / "gen_notrack_overlay.mp4"
p_cotr = clip_dir / "gen_full_cotracker.mp4"
imageio.mimsave(p_full, overlay_full, fps=args.fps, macro_block_size=1)
imageio.mimsave(p_no, overlay_no, fps=args.fps, macro_block_size=1)
imageio.mimsave(p_cotr, overlay_cotr, fps=args.fps, macro_block_size=1)
print(f"[eval] clip {idx}: EPE_active_full={epe_active_full:.2f} "
f"EPE_active_notrack={epe_active_no:.2f} DELTA={delta:.2f} "
f"EPE_bg={epe_bg:.2f}", flush=True)
# per-clip logs
entry = {
"clip": idx,
"epe_active_full_px": epe_active_full,
"epe_active_notrack_px": epe_active_no,
"delta_px": delta,
"epe_bg_px": epe_bg,
"n_active": sc["n_active"],
"n_bg": sc["n_bg"],
"sec_gen_full": t_full,
"sec_gen_notrack": t_no,
"caption": s["caption"][:120],
}
per_clip.append(entry)
wandb.log({
f"clip_{idx:03d}/gen_full": wandb.Video(str(p_full), fps=args.fps, format="mp4"),
f"clip_{idx:03d}/gen_notrack": wandb.Video(str(p_no), fps=args.fps, format="mp4"),
f"clip_{idx:03d}/gen_full_cotracker": wandb.Video(str(p_cotr), fps=args.fps, format="mp4"),
"clip": idx,
"epe_active_full_px": epe_active_full,
"epe_active_notrack_px": epe_active_no,
"delta_px": delta,
"epe_bg_px": epe_bg,
})
# ── Aggregate + summary ──
if per_clip:
arr = lambda k: np.array([r[k] for r in per_clip if not np.isnan(r[k])])
summary = {
"n_clips": len(per_clip),
"mean_epe_active_full_px": float(np.mean(arr("epe_active_full_px"))),
"median_epe_active_full_px": float(np.median(arr("epe_active_full_px"))),
"mean_epe_active_notrack_px": float(np.mean(arr("epe_active_notrack_px"))),
"median_epe_active_notrack_px": float(np.median(arr("epe_active_notrack_px"))),
"mean_delta_px": float(np.mean(arr("delta_px"))),
"median_delta_px": float(np.median(arr("delta_px"))),
"mean_epe_bg_px": float(np.mean(arr("epe_bg_px"))),
}
(Path(args.out_dir) / f"{args.wandb_run_name}_summary.json").write_text(json.dumps({
"summary": summary, "per_clip": per_clip}, indent=2))
wandb.log({f"summary/{k}": v for k, v in summary.items()})
wandb.summary.update(summary)
print("=" * 70)
print(f"[eval] summary ({len(per_clip)} clips):")
for k, v in summary.items():
print(f" {k}: {v:.3f}" if isinstance(v, float) else f" {k}: {v}")
print("=" * 70)
wandb.finish()
if __name__ == "__main__":
main()