quality-compare.py 8.7 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195
  1. #!/usr/bin/env python3
  2. """Objective quality verdict on an encode — VMAF/SSIM/PSNR vs the reference.
  3. Closes the encode loop: "did my compression actually look ok" becomes a number
  4. and an exit code the caller can branch on. Handles the resolution mismatch case
  5. (distorted is auto-scaled to reference dimensions before comparison) and parses
  6. the metric filters' log-text output so the agent never has to.
  7. Usage: quality-compare.py [--metrics LIST] [--min-vmaf N] [--min-ssim N] [--json]
  8. <reference> <distorted>
  9. Input: reference (original) and distorted (encoded) files as positionals
  10. Output: stdout = metric lines (or --json envelope,
  11. schema claude-mods.ffmpeg-ops.quality/v1)
  12. Stderr: progress, errors
  13. Exit: 0 ok / at-or-above thresholds, 2 usage, 3 input missing,
  14. 4 metric parse failure, 5 ffmpeg missing (or libvmaf absent when
  15. vmaf requested), 10 BELOW a requested threshold
  16. Guide: VMAF >= 93 at 1080p ~ visually transparent; 80-93 noticeable on
  17. inspection; < 80 visibly degraded. SSIM >= 0.98 ~ excellent.
  18. Examples:
  19. quality-compare.py original.mp4 encoded.mp4
  20. quality-compare.py original.mp4 encoded.mp4 --metrics vmaf --min-vmaf 90
  21. quality-compare.py original.mp4 encoded.mp4 --metrics ssim,psnr --min-ssim 0.97
  22. quality-compare.py original.mp4 encoded.mp4 --metrics vmaf --json | jq '.data.vmaf'
  23. """
  24. import argparse
  25. import json
  26. import re
  27. import shutil
  28. import subprocess
  29. import sys
  30. import tempfile
  31. from pathlib import Path
  32. from typing import NoReturn, Optional
  33. SCHEMA = "claude-mods.ffmpeg-ops.quality/v1"
  34. EXIT_OK, EXIT_USAGE, EXIT_NOT_FOUND, EXIT_VALIDATION = 0, 2, 3, 4
  35. EXIT_MISSING_DEP, EXIT_BELOW_THRESHOLD = 5, 10
  36. def err(json_mode: bool, code: str, message: str, exit_code: int) -> NoReturn:
  37. if json_mode:
  38. print(json.dumps({"error": {"code": code, "message": message, "details": {}}}))
  39. print(f"ERROR: {message}", file=sys.stderr)
  40. sys.exit(exit_code)
  41. def video_dims(ffprobe: str, path: Path) -> Optional[tuple]:
  42. proc = subprocess.run(
  43. [ffprobe, "-v", "error", "-select_streams", "v:0",
  44. "-show_entries", "stream=width,height", "-of", "csv=p=0", str(path)],
  45. capture_output=True, text=True)
  46. parts = proc.stdout.strip().split(",")
  47. if len(parts) == 2 and all(p.isdigit() for p in parts):
  48. return int(parts[0]), int(parts[1])
  49. return None
  50. def has_filter(ffmpeg: str, name: str) -> bool:
  51. proc = subprocess.run([ffmpeg, "-hide_banner", "-filters"],
  52. capture_output=True, text=True)
  53. return bool(re.search(rf"^\s+[A-Z.|]+\s+{re.escape(name)}\s+", proc.stdout,
  54. re.MULTILINE))
  55. def run_metric(ffmpeg: str, ref: Path, dist: Path, scale: str,
  56. metric_filter: str, cwd: Optional[str] = None) -> subprocess.CompletedProcess:
  57. # libvmaf/ssim/psnr convention: first input = distorted, second = reference.
  58. # cwd is set for vmaf so log_path can be a bare filename — a full Windows
  59. # path inside the filter arg hits the drive-colon escaping trap.
  60. graph = f"[0:v]{scale}[d];[d][1:v]{metric_filter}" if scale \
  61. else f"[0:v][1:v]{metric_filter}"
  62. return subprocess.run(
  63. [ffmpeg, "-hide_banner", "-nostats",
  64. "-i", str(dist.resolve()), "-i", str(ref.resolve()),
  65. "-filter_complex", graph, "-f", "null", "-"],
  66. capture_output=True, text=True, cwd=cwd)
  67. def main() -> int:
  68. ap = argparse.ArgumentParser(
  69. description="VMAF/SSIM/PSNR quality verdict: encoded vs reference.",
  70. epilog="Examples:\n"
  71. " quality-compare.py original.mp4 encoded.mp4\n"
  72. " quality-compare.py original.mp4 encoded.mp4 --metrics vmaf --min-vmaf 90\n",
  73. formatter_class=argparse.RawDescriptionHelpFormatter)
  74. ap.add_argument("reference", help="original/reference file")
  75. ap.add_argument("distorted", help="encoded/processed file to judge")
  76. ap.add_argument("--metrics", default="ssim,psnr",
  77. help="comma list of ssim,psnr,vmaf (default ssim,psnr)")
  78. ap.add_argument("--min-vmaf", type=float, default=None,
  79. help="exit 10 if VMAF score is below this")
  80. ap.add_argument("--min-ssim", type=float, default=None,
  81. help="exit 10 if SSIM (All) is below this")
  82. ap.add_argument("--json", action="store_true", help="emit JSON envelope on stdout")
  83. args = ap.parse_args()
  84. metrics = [m.strip().lower() for m in args.metrics.split(",") if m.strip()]
  85. bad = [m for m in metrics if m not in ("ssim", "psnr", "vmaf")]
  86. if bad or not metrics:
  87. err(args.json, "USAGE", f"unknown metric(s): {', '.join(bad) or '(none)'}",
  88. EXIT_USAGE)
  89. if args.min_vmaf is not None and "vmaf" not in metrics:
  90. metrics.append("vmaf")
  91. ffmpeg, ffprobe = shutil.which("ffmpeg"), shutil.which("ffprobe")
  92. if not ffmpeg or not ffprobe:
  93. err(args.json, "MISSING_DEPENDENCY", "ffmpeg/ffprobe not found on PATH",
  94. EXIT_MISSING_DEP)
  95. ref, dist = Path(args.reference), Path(args.distorted)
  96. for p in (ref, dist):
  97. if not p.is_file():
  98. err(args.json, "NOT_FOUND", f"file not found: {p}", EXIT_NOT_FOUND)
  99. if "vmaf" in metrics and not has_filter(ffmpeg, "libvmaf"):
  100. err(args.json, "MISSING_DEPENDENCY",
  101. "this ffmpeg build lacks libvmaf (install a full build, e.g. "
  102. "gyan.dev 'full' on Windows, or use --metrics ssim,psnr)",
  103. EXIT_MISSING_DEP)
  104. ref_dims, dist_dims = video_dims(ffprobe, ref), video_dims(ffprobe, dist)
  105. if not ref_dims or not dist_dims:
  106. err(args.json, "VALIDATION", "could not read video dimensions from inputs",
  107. EXIT_VALIDATION)
  108. scale = ""
  109. if ref_dims != dist_dims:
  110. scale = f"scale={ref_dims[0]}:{ref_dims[1]}:flags=bicubic"
  111. print(f"note: scaling distorted {dist_dims[0]}x{dist_dims[1]} -> "
  112. f"{ref_dims[0]}x{ref_dims[1]} for comparison", file=sys.stderr)
  113. results: dict = {}
  114. for metric in metrics:
  115. print(f"running {metric}...", file=sys.stderr)
  116. if metric == "vmaf":
  117. with tempfile.TemporaryDirectory() as td:
  118. log = Path(td) / "vmaf.json"
  119. proc = run_metric(ffmpeg, ref, dist, scale,
  120. "libvmaf=log_fmt=json:log_path=vmaf.json", cwd=td)
  121. if proc.returncode != 0 or not log.is_file():
  122. err(args.json, "VALIDATION",
  123. f"vmaf run failed: {(proc.stderr.strip().splitlines() or ['?'])[-1]}",
  124. EXIT_VALIDATION)
  125. vmaf_data = json.loads(log.read_text())
  126. pooled = vmaf_data.get("pooled_metrics", {}).get("vmaf", {})
  127. results["vmaf"] = {"mean": round(pooled.get("mean", 0.0), 2),
  128. "min": round(pooled.get("min", 0.0), 2),
  129. "harmonic_mean": round(pooled.get("harmonic_mean", 0.0), 2)}
  130. elif metric == "ssim":
  131. proc = run_metric(ffmpeg, ref, dist, scale, "ssim")
  132. m = re.search(r"SSIM.*All:([\d.]+)", proc.stderr)
  133. if not m:
  134. err(args.json, "VALIDATION", "could not parse SSIM output",
  135. EXIT_VALIDATION)
  136. results["ssim"] = {"all": float(m.group(1))}
  137. elif metric == "psnr":
  138. proc = run_metric(ffmpeg, ref, dist, scale, "psnr")
  139. m = re.search(r"PSNR.*average:([\d.]+|inf)", proc.stderr)
  140. if not m:
  141. err(args.json, "VALIDATION", "could not parse PSNR output",
  142. EXIT_VALIDATION)
  143. val = m.group(1)
  144. results["psnr"] = {"average_db": float("inf") if val == "inf" else float(val)}
  145. below = []
  146. if args.min_vmaf is not None and results.get("vmaf", {}).get("mean", 1e9) < args.min_vmaf:
  147. below.append(f"vmaf {results['vmaf']['mean']} < {args.min_vmaf}")
  148. if args.min_ssim is not None and results.get("ssim", {}).get("all", 1e9) < args.min_ssim:
  149. below.append(f"ssim {results['ssim']['all']} < {args.min_ssim}")
  150. data = {"reference": str(ref), "distorted": str(dist),
  151. "scaled_for_comparison": bool(scale),
  152. "thresholds": {"min_vmaf": args.min_vmaf, "min_ssim": args.min_ssim},
  153. "below_threshold": below, **results}
  154. if args.json:
  155. print(json.dumps({"data": data, "meta": {"schema": SCHEMA}}, indent=2))
  156. else:
  157. for name, vals in results.items():
  158. flat = " ".join(f"{k}={v}" for k, v in vals.items())
  159. print(f"{name}\t{flat}")
  160. for b in below:
  161. print(f"below-threshold\t{b}")
  162. if below:
  163. print(f"VERDICT: below threshold ({'; '.join(below)})", file=sys.stderr)
  164. return EXIT_BELOW_THRESHOLD
  165. return EXIT_OK
  166. if __name__ == "__main__":
  167. sys.exit(main())