#!/usr/bin/env python3 """Compare Fine-Tuned vs Merged/Base+LoRA inference outputs. Generates two videos with the same seed and computes SSIM. Usage examples: python lora_inference_comparison.py \ --base ./merged_model \ --ft FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers \ --adapter NONE \ --output-dir ./inference_comparison \ --compute-ssim \ --seed 41 or python lora_inference_comparison.py \ --base Wan-AI/Wan2.2-TI2V-5B-Diffusers \ --ft FastVideo/FastWan2.2-TI2V-5B-FullAttn-Diffusers \ --adapter adapter.safetensors \ --output-dir ./inference_comparison \ --compute-ssim \ --seed 41 """ from __future__ import annotations import argparse import os import sys from pathlib import Path from typing import Optional, Dict, Any import logging # minimal distributed env defaults (kept for compatibility) os.environ.setdefault("MASTER_ADDR", "127.0.0.1") os.environ.setdefault("MASTER_PORT", "29500") os.environ.setdefault("WORLD_SIZE", "1") os.environ.setdefault("RANK", "0") os.environ.setdefault("LOCAL_RANK", "0") # allow running from repo root where fastvideo is located _FASTVIDEO_PATH = os.path.abspath(os.path.join(os.path.dirname(__file__), "..", "fastvideo_pr", "FastVideo")) if _FASTVIDEO_PATH not in sys.path: sys.path.insert(0, _FASTVIDEO_PATH) logger = logging.getLogger("inference_comparison") def configure_logging(level: str = "INFO") -> None: handler = logging.StreamHandler() fmt = "%(asctime)s %(levelname)s %(message)s" handler.setFormatter(logging.Formatter(fmt, datefmt="%Y-%m-%d %H:%M:%S")) logger.addHandler(handler) logger.setLevel(level) def _validate_adapter(adapter: Optional[str]) -> Optional[str]: if not adapter: return None if adapter.upper() == "NONE": return None p = Path(adapter).expanduser() if not p.exists(): raise FileNotFoundError(f"Adapter not found: {p}") # Accept both files and directories (FastVideo expects directories for HF-style adapters) if p.is_file(): if p.suffix != ".safetensors": raise ValueError(f"Adapter file must be .safetensors, got: {p.suffix}") if p.stat().st_size == 0: raise ValueError(f"Adapter file is empty: {p}") elif p.is_dir(): # Check if directory contains at least one .safetensors file safetensors_files = list(p.glob("*.safetensors")) if not safetensors_files: raise ValueError(f"Adapter directory contains no .safetensors files: {p}") else: raise ValueError(f"Adapter must be a file or directory: {p}") return str(p.resolve()) def generate_with_model( model_path: str, output_dir: str, output_name: str, prompt: str, seed: int, lora_path: Optional[str], height: int, width: int, num_frames: int, num_inference_steps: int, guidance_scale: float, flow_shift: Optional[float] = None, embedded_guidance_scale: Optional[float] = None, ) -> str: """Produce a video with VideoGenerator.from_pretrained; returns video path.""" try: from fastvideo import VideoGenerator # lazy import except Exception as exc: raise RuntimeError(f"Failed to import fastvideo.VideoGenerator: {exc}") from exc init_kwargs: Dict[str, Any] = { "num_gpus": 1, "dit_cpu_offload": True, "vae_cpu_offload": True, "text_encoder_cpu_offload": True, "pin_cpu_memory": True, } if lora_path: init_kwargs["lora_path"] = lora_path init_kwargs["lora_nickname"] = "extracted" generator = VideoGenerator.from_pretrained(model_path, **init_kwargs) gen_kwargs = { "height": height, "width": width, "num_frames": num_frames, "num_inference_steps": num_inference_steps, "guidance_scale": guidance_scale, "seed": seed, "output_path": output_dir, "output_video_name": output_name, "save_video": True, } if flow_shift is not None: gen_kwargs["flow_shift"] = flow_shift if embedded_guidance_scale is not None: gen_kwargs["embedded_guidance_scale"] = embedded_guidance_scale result = generator.generate_video(prompt, **gen_kwargs) # best-effort cleanup of internal executors try: if hasattr(generator, "executor") and hasattr(generator.executor, "shutdown"): generator.executor.shutdown() except Exception: pass # determine saved video path expected = Path(output_dir) / f"{output_name}.mp4" if expected.exists(): return str(expected) # fallback: check result dict if isinstance(result, dict) and "video_path" in result: return str(result["video_path"]) raise FileNotFoundError(f"Video not found at expected path: {expected}") def compute_metrics(output_dir: str, ft_video: str, other_video: str, num_inference_steps: int, prompt: str, compute_ssim: bool, compute_lpips: bool) -> dict: results = {} if compute_ssim: try: from fastvideo.tests.utils import compute_video_ssim_torchvision, write_ssim_results # type: ignore ssim_values = compute_video_ssim_torchvision(ft_video, other_video, use_ms_ssim=True) results["mean_ssim"] = float(ssim_values[0]) write_ssim_results(output_dir, ssim_values, ft_video, other_video, num_inference_steps, prompt) except Exception as e: logger.warning(f"SSIM computation failed: {e}") if compute_lpips: try: import torch import lpips import torchvision.io as tv_io loss_fn = lpips.LPIPS(net='alex') # Load videos vid1, _, _ = tv_io.read_video(ft_video, pts_unit='sec') vid2, _, _ = tv_io.read_video(other_video, pts_unit='sec') # Normalize to [-1, 1] vid1 = (vid1.float() / 127.5 - 1.0).permute(0, 3, 1, 2) # (T, C, H, W) vid2 = (vid2.float() / 127.5 - 1.0).permute(0, 3, 1, 2) lpips_scores = [] with torch.no_grad(): for frame1, frame2 in zip(vid1, vid2): score = loss_fn(frame1.unsqueeze(0), frame2.unsqueeze(0)) lpips_scores.append(float(score.item())) results["mean_lpips"] = sum(lpips_scores) / len(lpips_scores) # Write LPIPS results import json lpips_file = Path(output_dir) / f"steps{num_inference_steps}_{prompt.replace(' ', '_')[:30]}_lpips.json" with open(lpips_file, 'w') as f: json.dump({"mean_lpips": results["mean_lpips"], "lpips_per_frame": lpips_scores}, f, indent=2) except Exception as e: logger.warning(f"LPIPS computation failed: {e}") return results def parse_args() -> argparse.Namespace: p = argparse.ArgumentParser(description="Compare Fine-Tuned vs Merged/Base+LoRA inference outputs") p.add_argument("--base", required=True, help="Base model ID or merged model path") p.add_argument("--ft", required=True, help="Fine-tuned model ID or path (reference)") p.add_argument("--adapter", default="NONE", help="Path to .safetensors adapter, or NONE to use merged model") p.add_argument("--output-dir", default="./inference_comparison") p.add_argument("--prompt", default="A cat sitting on a windowsill") p.add_argument("--seed", type=int, default=42) p.add_argument("--height", type=int, default=480) p.add_argument("--width", type=int, default=832) p.add_argument("--num-frames", type=int, default=49) p.add_argument("--num-inference-steps", type=int, default=32) p.add_argument("--guidance-scale", type=float, default=6.0) p.add_argument("--compute-ssim", action="store_true") p.add_argument("--compute-lpips", action="store_true") p.add_argument("--flow-shift", type=float, default=None) p.add_argument("--embedded-guidance-scale", type=float, default=None) p.add_argument("--log-level", default="INFO") return p.parse_args() def compare_inference( base: str, ft: str, adapter: Optional[str], output_dir: str, prompt: str = "A cat sitting on a windowsill", seed: int = 42, height: int = 480, width: int = 832, num_frames: int = 49, num_inference_steps: int = 32, guidance_scale: float = 5.0, flow_shift: Optional[float] = None, embedded_guidance_scale: Optional[float] = None, compute_ssim: bool = False, compute_lpips: bool = False, log_level: str = "INFO", ) -> dict: """Compare inference between fine-tuned model and merged/base+adapter model. Args: base: Base or merged model ID/path ft: Fine-tuned model ID/path adapter: LoRA adapter path (or NONE for merged model) output_dir: Output directory for videos prompt: Generation prompt seed: Random seed height: Video height width: Video width num_frames: Number of frames num_inference_steps: Inference steps guidance_scale: CFG scale flow_shift: Flow shift embedded_guidance_scale: Embedded guidance scale compute_ssim: Compute SSIM metric compute_lpips: Compute LPIPS metric log_level: Logging level Returns: Dictionary with metric results """ configure_logging(log_level) os.makedirs(output_dir, exist_ok=True) try: adapter_path = _validate_adapter(adapter) except Exception as exc: logger.error("Adapter validation failed: %s", exc) sys.exit(2) # 1) generate with fine-tuned model (reference) logger.info("Generating reference (fine-tuned): %s", ft) try: ft_video = generate_with_model( model_path=ft, output_dir=output_dir, output_name="fine_tuned", prompt=prompt, seed=seed, lora_path=None, height=height, width=width, num_frames=num_frames, num_inference_steps=num_inference_steps, guidance_scale=guidance_scale, flow_shift=flow_shift, embedded_guidance_scale=embedded_guidance_scale, ) logger.info("Reference video saved: %s", ft_video) except Exception as exc: logger.error("Reference generation failed: %s", exc) sys.exit(3) # 2) generate with merged model OR base + adapter use_merged = adapter_path is None mode = "merged model" if use_merged else "base+adapter" logger.info("Generating target (%s): %s", mode, base) try: target_video = generate_with_model( model_path=base, output_dir=output_dir, output_name="merged_model" if use_merged else "base_plus_lora", prompt=prompt, seed=seed, lora_path=None if use_merged else adapter_path, height=height, width=width, num_frames=num_frames, num_inference_steps=num_inference_steps, guidance_scale=guidance_scale, flow_shift=flow_shift, embedded_guidance_scale=embedded_guidance_scale, ) logger.info("Target video saved: %s", target_video) except Exception as exc: logger.error("Target generation failed: %s", exc) sys.exit(4) # 3) compute metrics results = {} if compute_ssim or compute_lpips: results = compute_metrics(output_dir, ft_video, target_video, num_inference_steps, prompt, compute_ssim, compute_lpips) if results.get("mean_ssim") is not None: logger.info("Mean SSIM: %.4f", results["mean_ssim"]) if results.get("mean_lpips") is not None: logger.info("Mean LPIPS: %.4f", results["mean_lpips"]) logger.info("Comparison complete. Videos in: %s", output_dir) return results def main() -> None: """CLI wrapper for compare_inference.""" args = parse_args() compare_inference( base=args.base, ft=args.ft, adapter=args.adapter, output_dir=args.output_dir, prompt=args.prompt, seed=args.seed, height=args.height, width=args.width, num_frames=args.num_frames, num_inference_steps=args.num_inference_steps, guidance_scale=args.guidance_scale, flow_shift=args.flow_shift, embedded_guidance_scale=args.embedded_guidance_scale, compute_ssim=args.compute_ssim, compute_lpips=args.compute_lpips, log_level=args.log_level, ) if __name__ == "__main__": main()