From 0ce0fd932325af082bae239c71f20ad9efbec5bb Mon Sep 17 00:00:00 2001 From: Chris Seeger Date: Fri, 6 Mar 2026 20:12:59 -0500 Subject: [PATCH 1/7] homebrew formula for more capable ffmpeg Adds zimg for zscale and other necessary operations --- build/homebrew/ffmpeg-custom.rb | 124 ++++++++++++++++++++++++++++++++ 1 file changed, 124 insertions(+) create mode 100644 build/homebrew/ffmpeg-custom.rb diff --git a/build/homebrew/ffmpeg-custom.rb b/build/homebrew/ffmpeg-custom.rb new file mode 100644 index 0000000..e43f8f4 --- /dev/null +++ b/build/homebrew/ffmpeg-custom.rb @@ -0,0 +1,124 @@ +class FfmpegCustom < Formula + desc "FFmpeg with stock Homebrew features plus additional codecs and libraries" + homepage "https://ffmpeg.org/" + url "https://ffmpeg.org/releases/ffmpeg-8.0.1.tar.xz" + sha256 "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41" + revision 2 + license "GPL-2.0-or-later" + + depends_on "pkgconf" => :build + + # Stock Homebrew ffmpeg dependencies + depends_on "dav1d" + depends_on "lame" + depends_on "libvpx" + depends_on "openssl@3" + depends_on "opus" + depends_on "sdl2" + depends_on "svt-av1" + depends_on "x264" + depends_on "x265" + + # Additional custom dependencies + depends_on "aom" + depends_on "fdk-aac" + depends_on "freetype" + depends_on "harfbuzz" + depends_on "libass" + depends_on "librist" + depends_on "libsoxr" + depends_on "libvmaf" + depends_on "openjpeg" + depends_on "srt" + depends_on "two-lame" + depends_on "zimg" + + uses_from_macos "bzip2" + uses_from_macos "libxml2" + uses_from_macos "zlib" + + on_intel do + depends_on "nasm" => :build + end + + on_linux do + depends_on "alsa-lib" + depends_on "libxcb" + depends_on "xz" + depends_on "zlib-ng-compat" + end + + patch do + url "https://gitlab.archlinux.org/archlinux/packaging/packages/ffmpeg/-/raw/5670ccd86d3b816f49ebc18cab878125eca2f81f/add-av_stream_get_first_dts-for-chromium.patch" + sha256 "57e26caced5a1382cb639235f9555fc50e45e7bf8333f7c9ae3d49b3241d3f77" + end + + patch do + url "https://git.ffmpeg.org/gitweb/ffmpeg.git/patch/a5d4c398b411a00ac09d8fe3b66117222323844c" + sha256 "1dbbc1a4cf9834b3902236abc27fefe982da03a14bcaa89fb90c7c8bd10a1664" + end + + def install + ENV.append "LDFLAGS", "-Wl,-ld_classic" if OS.mac? && DevelopmentTools.ld64_version.between?("1015.7", "1022.1") + + args = %W[ + --prefix=#{prefix} + --enable-shared + --enable-pthreads + --enable-version3 + --enable-gpl + --enable-nonfree + + --cc=#{ENV.cc} + --host-cflags=#{ENV.cflags} + --host-ldflags=#{ENV.ldflags} + + --enable-ffplay + --enable-libsvtav1 + --enable-libopus + --enable-libx264 + --enable-libmp3lame + --enable-libdav1d + --enable-libvpx + --enable-libx265 + --enable-openssl + + --enable-libaom + --enable-libfdk-aac + --enable-libtwolame + --enable-librist + --enable-libsrt + --enable-libass + --enable-libfreetype + --enable-libharfbuzz + --enable-libsoxr + --enable-libzimg + --enable-libvmaf + --enable-libopenjpeg + ] + + if system("pkg-config", "--exists", "fribidi") + args << "--enable-libfribidi" + end + + if OS.mac? + args << "--enable-videotoolbox" + args << "--enable-audiotoolbox" + end + + args << "--enable-neon" if Hardware::CPU.arm? + + system "./configure", *args + system "make" + system "make", "install" + system "make", "alltools" + + bin.install Dir["tools/*"].select { |f| File.file?(f) && File.executable?(f) } + pkgshare.install "tools/python" if Dir.exist?("tools/python") + end + + test do + output = shell_output("#{bin}/ffmpeg -version") + assert_match "ffmpeg version", output + end +end From 5f628654dd5cad545e83470a5afa5aea26070cb5 Mon Sep 17 00:00:00 2001 From: Chris Seeger Date: Fri, 6 Mar 2026 20:20:08 -0500 Subject: [PATCH 2/7] Builds per-frame heatmaps and JOD metrics ....once homebrew ffmpeg is installed along with symlinks in macOS on METAL (GPU) --- .../cvvdp_per_frame_csv_and_heatmap_v32.3.py | 1233 +++++++++++++++++ 1 file changed, 1233 insertions(+) create mode 100644 macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py diff --git a/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py b/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py new file mode 100644 index 0000000..97df151 --- /dev/null +++ b/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py @@ -0,0 +1,1233 @@ +#!/opt/homebrew/anaconda3/envs/cvvdp/bin/python +""" +cvvdp_per_frame_csv_and_heatmap_v32_2.py + +Features +-------- +• Drag-and-drop friendly (interactive prompts if paths not supplied) +• Path cleanup for macOS Terminal drag-drop (quotes, escaped spaces, CR) +• Extract REF+TEST to 16-bit RGB TIFF sequences ONCE (frame index source of truth) +• Optional full-screen resize is applied during TIFF extraction (ffmpeg scale to --display-res) + because ColorVideoVDP does NOT implement --full-screen-resize for IMAGE SEQUENCES. +• Per-frame CVVDP JOD + per-frame heatmap PNG (one PNG per source frame) + - Optional temporal window: run cvvdp on a multi-frame window (i-k):(i+k), but: + - Metric reported is for the window; we log it at the center frame index i + - Heatmap output may contain multiple frames; we extract ONLY the CENTER heatmap frame +• Optional per-frame PU-PSNR-RGB2020 (HDR-aware PSNR variant provided by cvvdp) +• Optional cvvdp debug dumps via: + --dump-channels temporal|lpyr|difference [...] + - Can optionally preserve those outputs with: + --dump-output-dir /path/to/folder +• Uses cvvdp INTERACTIVE MODE (-i) only when needed for optional PU-PSNR-RGB2020 +• Heatmap PNG sequence → ProRes MOV (CFR, no frame blending) +• Optional side-by-side compare MOV: (TEST | HEATMAP MOV) + - Automatically scales TEST to match HEATMAP height so hstack never fails + - If TEST/source is PQ, compare MOV is automatically mastered/tagged as PQ/BT.2020 + and the heatmap leg is up-mapped from SDR/BT.709 → linear display light → 2x scale + → BT.2020 → PQ so it looks correct in the HDR compare output +• Automatic PNG cleanup after successful heatmap MOV creation (keeps PNGs if MOV fails) +• CSV includes legends + options used (# key=value lines) + +Notes on temporal artifacts +--------------------------- +- If --temp-window=0 (default): CVVDP runs ONLY on frame i (no temporal measurement). +- If --temp-window>0: CVVDP runs on frames (i-k):(i+k). This allows the metric to + incorporate temporal mechanisms, BUT you must interpret the reported JOD as the + quality of that WINDOW. We log it against the center frame i. +- Heatmaps: when windowing is enabled, CVVDP may output multiple heatmap frames. + This script extracts the CENTER heatmap frame (i) so you still get exactly ONE PNG + per output frame. + +Modes +----- +--mode supra-threshold | threshold | raw + supra-threshold : perceptual supra-threshold difference map (often most useful) + threshold : detection-threshold map + raw : raw perceptual error energy map (implementation-dependent) + +USAGE: + python cvvdp_per_frame_csv_and_heatmap_v32_2.py REF.mov TEST.mov OUTDIR [options] + (or run without args and it will prompt you to drag/drop paths) + +Example: + python cvvdp_per_frame_csv_and_heatmap_v32_2.py ref.mov test.mov out \ + --display NBCU_65inch_hdr_pq_2knit --device mps --mode supra-threshold \ + --temp-window 0 --pu-psnr-rgb2020 + +IMPORTANT: +- pix/deg override is OPTIONAL. If not provided, the DISPLAY PROFILE governs pix/deg. + Use --pix-per-deg ONLY if you intentionally want to override the display model. + +ΔE-ITP +------ +Removed (prior implementation was not validated for HDR PQ/ICtCp correctness). + +--dump-channels temporal +------------------------ +Use when you’re trying to answer: “Is the metric reacting to time the way I think it is?” + +Best use-cases: + • Validate temporal-window choices (--temp-window, and in general whether motion/temporal masking is kicking in). + • Diagnose ‘temporal weirdness’: quality looks worse/better than expected during fast motion, cuts, flashes, flicker, or scrolling text. + • Debug framerate / cadence issues (e.g., accidental 23.976 vs 24 vs 59.94 conversions, duplicated frames, bad decimation). + • Sanity-check temporal padding (--temp-padding replicate|pingpong|circular) near start/end of clips when windowing is used. + +What you typically look for: + • “Temporal” intermediate outputs should show motion-related processing behaving sensibly + (not “stuck,” not exploding at cuts, not showing obvious cadence artifacts). + +--dump-channels lpyr +-------------------- +Use when you’re trying to answer: “Which spatial frequencies are triggering the score/heatmap?” + +Best use-cases: + • Find whether the ‘damage’ is high-frequency (ringing, sharpening halos, mosquito noise) vs mid/low (blur, banding, blocking). + • Compare two encodes where the JOD difference is small but you want to know why. + • Debug resize/sharpen pipelines (especially if you are scaling in ffmpeg before cvvdp by extracting TIFFs at a display-res). + +What you typically look for: + • If artifacts are mostly in high bands → expect ringing/noise; mid bands → texture loss; + low bands → luminance/contrast structure shifts. + +--dump-channels difference +-------------------------- +Use when you’re trying to answer: “What exact error field is cvvdp feeding into the final perceptual model?” + +Best use-cases: + • Explain surprising heatmaps: you see red blobs in weird places and want to know if it’s + from color conversion, luma differences, or content structure. + • Spot alignment problems (1px shifts, resample mismatch, scaling mismatch, chroma siting issues) + because the “difference” stage will often make these scream. + • Verify you’re not measuring decode/convert mistakes (wrong transfer interpretation, + wrong matrix/range assumptions) rather than actual codec differences. + +What you typically look for: + • A clean “difference” field that corresponds to real visible changes, not global offsets/tints + that imply upstream pipeline issues. + +How this ties back to optimized workflows +----------------------------------------- + • If you’re iterating on settings (temp window, scaling, framerate, padding) and need fast confidence, + --dump-channels temporal is the highest value. + • If you’re tuning encode settings (bitrate, psychovisual knobs, sharpening, grain), + --dump-channels lpyr is the best “why” tool. + • If you suspect you’re measuring the wrong thing (colorspace/TF/range mismatch, alignment), + --dump-channels difference saves the most time. +""" + +import argparse +import csv +import json +import re +import select +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Optional, Dict, List, Tuple + +VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".m4v"} +_FLOAT_LINE_RE = re.compile(r"^\s*[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\s*$") + + +# ------------------------------------------------------------- +# Path cleaning (drag-drop safe) +# ------------------------------------------------------------- + +def clean_path(p: str) -> str: + p = p.strip().replace("\r", "") + if len(p) >= 2 and ((p[0] == '"' and p[-1] == '"') or (p[0] == "'" and p[-1] == "'")): + p = p[1:-1] + p = p.replace("\\ ", " ") + return p + + +def prompt_path(prompt: str) -> str: + sys.stdout.write(prompt) + sys.stdout.flush() + buf: List[str] = [] + while True: + ch = sys.stdin.read(1) + if ch == "": + raise RuntimeError("EOF while reading input.") + if ch in ("\n", "\r"): + line = clean_path("".join(buf)) + if line: + return line + sys.stdout.write(prompt) + sys.stdout.flush() + buf = [] + continue + buf.append(ch) + + +# ------------------------------------------------------------- +# Command runner / exec discovery +# ------------------------------------------------------------- + +def run(cmd: List[str], verbose: bool = False) -> str: + if verbose: + print(" ".join(str(c) for c in cmd)) + p = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True + ) + if p.returncode != 0: + raise RuntimeError(f"Command failed ({p.returncode}): {' '.join(map(str, cmd))}\n\n{p.stdout}") + return p.stdout + + +def which_or_raise(name: str) -> str: + p = shutil.which(name) + if not p: + raise RuntimeError(f"Required executable not found on PATH: {name}") + return p + + +def find_cvvdp_exe() -> str: + p = shutil.which("cvvdp") + if p: + return p + + pybin = Path(sys.executable).resolve().parent + cand = pybin / "cvvdp" + if cand.exists(): + return str(cand) + + conda_prefix = Path(sys.executable).resolve().parent.parent + cand2 = conda_prefix / "bin" / "cvvdp" + if cand2.exists(): + return str(cand2) + + candidates = [ + Path("/opt/homebrew/anaconda3/envs"), + Path("/opt/homebrew/Caskroom/miniconda/base/envs"), + Path.home() / "miniconda3" / "envs", + Path.home() / "anaconda3" / "envs", + Path("/usr/local/anaconda3/envs"), + Path("/usr/local/miniconda3/envs"), + ] + for root in candidates: + if root.exists(): + for exe in root.glob("*/bin/cvvdp"): + if exe.exists(): + return str(exe) + + raise RuntimeError( + "Could not locate 'cvvdp' executable.\n" + f"sys.executable = {sys.executable}\n" + "Fix options:\n" + " A) Run with env python, e.g.: /opt/homebrew/anaconda3/envs/cvvdp/bin/python script.py\n" + " B) Put env on PATH, e.g.: export PATH=/opt/homebrew/anaconda3/envs/cvvdp/bin:$PATH\n" + " C) Or hardcode CVVDP_EXE in this script.\n" + ) + + +# ------------------------------------------------------------- +# ffprobe helpers +# ------------------------------------------------------------- + +def ffprobe_stream_meta(path: str) -> Dict[str, str]: + ffprobe = which_or_raise("ffprobe") + out = run([ + ffprobe, "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=codec_name,pix_fmt,color_space,color_transfer,color_primaries,bit_rate,width,height,r_frame_rate,color_range", + "-of", "json", + path + ]) + j = json.loads(out) + s = (j.get("streams") or [{}])[0] + + def g(k: str) -> str: + v = s.get(k, "") + return "" if v is None else str(v) + + return { + "codec_name": g("codec_name"), + "pix_fmt": g("pix_fmt"), + "color_space": g("color_space"), + "color_transfer": g("color_transfer"), + "color_primaries": g("color_primaries"), + "color_range": g("color_range"), + "bit_rate": g("bit_rate"), + "width": g("width"), + "height": g("height"), + "r_frame_rate": g("r_frame_rate"), + } + + +def ffprobe_r_frame_rate(path: str) -> str: + r = ffprobe_stream_meta(path).get("r_frame_rate", "") + if not r: + raise RuntimeError("ffprobe did not return r_frame_rate.") + return r + + +def r_frame_rate_to_float(r: str) -> float: + if "/" in r: + n, d = r.split("/") + return float(n) / float(d) + return float(r) + + +def ffprobe_wh(path: str) -> Tuple[int, int]: + ffprobe = which_or_raise("ffprobe") + out = run([ + ffprobe, "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0:s=x", + path + ]) + s = out.strip().splitlines()[0] + w, h = s.split("x") + return int(w), int(h) + + +def is_pq_video(meta: Dict[str, str]) -> bool: + trc = (meta.get("color_transfer") or "").strip().lower() + return trc in {"smpte2084", "pq"} + + +def ffmpeg_has_zscale() -> bool: + ffmpeg = which_or_raise("ffmpeg") + try: + out = run([ffmpeg, "-hide_banner", "-filters"]) + for line in out.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[1] == "zscale": + return True + return False + except Exception: + return False + + +# ------------------------------------------------------------- +# Display-res parsing / scaling flags +# ------------------------------------------------------------- + +def parse_display_res(res: str) -> Tuple[int, int]: + s = res.lower().replace(" ", "") + if "x" not in s: + raise ValueError(f"--display-res must be like 3840x2160, got: {res}") + w, h = s.split("x", 1) + return int(w), int(h) + + +def ffmpeg_scale_flags(full_screen_resize: str) -> str: + m = { + "bilinear": "bilinear", + "bicubic": "bicubic", + "nearest": "neighbor", + "area": "area", + } + return m[full_screen_resize] + + +# ------------------------------------------------------------- +# Frame extraction (TIFF) +# ------------------------------------------------------------- + +def extract_tiffs(video: str, + out_dir: Path, + prefix: str, + full_screen_resize: str, + display_res: Tuple[int, int], + verbose: bool = False) -> Path: + ffmpeg = which_or_raise("ffmpeg") + out_dir.mkdir(parents=True, exist_ok=True) + pattern = out_dir / f"{prefix}_%06d.tif" + + w, h = display_res + flags = ffmpeg_scale_flags(full_screen_resize) + vf = f"scale={w}:{h}:flags={flags}" + + run([ + ffmpeg, "-hide_banner", "-y", + "-i", video, + "-vsync", "0", + "-start_number", "0", + "-vf", vf, + "-pix_fmt", "rgb48le", + str(pattern) + ], verbose=verbose) + + return pattern + + +def count_tiffs(out_dir: Path, prefix: str) -> int: + return len(list(out_dir.glob(f"{prefix}_*.tif"))) + + +# ------------------------------------------------------------- +# CVVDP parsing helpers +# ------------------------------------------------------------- + +def parse_metric_value(output: str) -> Optional[float]: + s = output.strip() + if not s: + return None + + if "\n" not in s: + try: + return float(s) + except Exception: + pass + + for line in s.splitlines(): + t = line.strip() + if "[JOD]" in t and "=" in t: + try: + return float(t.split("=", 1)[1].split()[0]) + except Exception: + continue + + for line in s.splitlines(): + t = line.strip() + if "=" in t: + try: + return float(t.split("=", 1)[1].split()[0]) + except Exception: + pass + else: + try: + return float(t.split()[0]) + except Exception: + pass + + return None + + +# ------------------------------------------------------------- +# Interactive cvvdp helpers +# ------------------------------------------------------------- + +def _join_tokens_for_interactive(tokens: List[str]) -> str: + return " ".join(shlex.quote(t) for t in tokens) + + +def start_cvvdp_interactive(cvvdp_exe: str, verbose: bool = False) -> subprocess.Popen: + cmd = [cvvdp_exe, "-i"] + if verbose: + print("Starting cvvdp interactive:", " ".join(cmd)) + p = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert p.stdin and p.stdout + return p + + +def _read_one_result_block(proc: subprocess.Popen, timeout_sec: float = 180.0) -> str: + assert proc.stdout + t0 = time.time() + lines: List[str] = [] + + while True: + if time.time() - t0 > timeout_sec: + raise RuntimeError("cvvdp interactive read timed out") + + line = proc.stdout.readline() + if line == "": + raise RuntimeError("cvvdp interactive process ended unexpectedly") + + lines.append(line) + s = line.strip() + + if _FLOAT_LINE_RE.match(s) or (s.startswith("cvvdp") and "=" in s): + end_t = time.time() + 0.05 + while time.time() < end_t: + r, _, _ = select.select([proc.stdout], [], [], 0.0) + if not r: + break + more = proc.stdout.readline() + if more == "": + break + lines.append(more) + return "".join(lines) + + +def cvvdp_interactive_run(proc: subprocess.Popen, tokens: List[str], verbose: bool = False) -> str: + assert proc is not None + assert proc.stdin and proc.stdout + line = _join_tokens_for_interactive(tokens) + "\n" + if verbose: + print("cvvdp(i) <=", line.strip()) + proc.stdin.write(line) + proc.stdin.flush() + out = _read_one_result_block(proc) + if verbose: + print("cvvdp(i) =>", out.strip()) + return out + + +def stop_cvvdp_interactive(proc: subprocess.Popen) -> None: + try: + if proc.stdin: + proc.stdin.close() + except Exception: + pass + try: + proc.terminate() + except Exception: + pass + try: + proc.wait(timeout=2.0) + except Exception: + try: + proc.kill() + except Exception: + pass + + +# ------------------------------------------------------------- +# Heatmap extraction helpers +# ------------------------------------------------------------- + +def _extract_center_frame_from_video(src_video: Path, out_png: Path, center_index: int, verbose: bool = False) -> None: + ffmpeg = which_or_raise("ffmpeg") + out_png.parent.mkdir(parents=True, exist_ok=True) + vf = f"select='eq(n\\,{center_index})'" + run([ + ffmpeg, "-hide_banner", "-y", + "-i", str(src_video), + "-vf", vf, + "-frames:v", "1", + str(out_png) + ], verbose=verbose) + + +def _extract_single_png_or_convert(src_img: Path, out_png: Path, verbose: bool = False) -> None: + ffmpeg = which_or_raise("ffmpeg") + out_png.parent.mkdir(parents=True, exist_ok=True) + if src_img.suffix.lower() == ".png": + out_png.write_bytes(src_img.read_bytes()) + else: + run([ + ffmpeg, "-hide_banner", "-y", + "-i", str(src_img), + "-frames:v", "1", + str(out_png) + ], verbose=verbose) + + +# ------------------------------------------------------------- +# Run CVVDP: JOD + HEATMAP (window) → center-frame heatmap PNG +# ------------------------------------------------------------- + +def run_cvvdp_window_and_heatmap( + cvvdp_exe: str, + ref_pat: Path, + test_pat: Path, + center_frame: int, + temp_window: int, + display: str, + device: str, + fps: float, + heatmap_mode: str, + pix_per_deg: Optional[float], + temp_resample: bool, + temp_padding: str, + dump_channels: List[str], + dump_output_dir: str, + out_png: Path, + verbose: bool = False +) -> float: + k = max(0, int(temp_window)) + start = max(0, int(center_frame) - k) + end = int(center_frame) + k + + temp_ctx = None + try: + if dump_channels and dump_output_dir: + td_path = Path(dump_output_dir).expanduser().resolve() / f"frame_{center_frame:06d}" + if td_path.exists(): + shutil.rmtree(td_path) + td_path.mkdir(parents=True, exist_ok=True) + else: + temp_ctx = tempfile.TemporaryDirectory(prefix="cvvdp_win_") + td_path = Path(temp_ctx.name) + + cmd = [ + str(cvvdp_exe), + "--ffmpeg-cc", + "--device", str(device), + "--display", str(display), + "--fps", f"{float(fps):.12f}", + "--frames", f"{start}:{end}", + "--heatmap", str(heatmap_mode), + "--output-dir", str(td_path), + "--ref", str(ref_pat), + "--test", str(test_pat), + ] + + if temp_resample: + cmd.insert(1, "--temp-resample") + + if temp_padding: + cmd.insert(1, temp_padding) + cmd.insert(1, "--temp-padding") + + if dump_channels: + idx = cmd.index("--output-dir") + cmd[idx:idx] = ["--dump-channels", *dump_channels] + + if pix_per_deg is not None: + idx = cmd.index("--fps") + cmd[idx:idx] = ["--pix-per-deg", str(pix_per_deg)] + + out = run(cmd, verbose=verbose) + + jod = parse_metric_value(out) + if jod is None: + raise RuntimeError( + f"Could not parse JOD for center_frame={center_frame}\n--- cvvdp output ---\n{out}" + ) + + heat_imgs = sorted([ + p for p in td_path.iterdir() + if p.is_file() + and "heatmap" in p.name.lower() + and p.suffix.lower() in {".png", ".tif", ".tiff"} + ]) + heat_vids = sorted([ + p for p in td_path.iterdir() + if p.is_file() + and "heatmap" in p.name.lower() + and p.suffix.lower() in VIDEO_EXTS + ]) + + if heat_imgs: + _extract_single_png_or_convert(heat_imgs[0], out_png, verbose=verbose) + elif heat_vids: + center_idx = max(0, int(center_frame) - start) + _extract_center_frame_from_video(heat_vids[0], out_png, center_idx, verbose=verbose) + else: + listing = "\n".join([p.name for p in td_path.iterdir()]) + raise RuntimeError( + f"No heatmap output produced for center_frame={center_frame}.\nDir:\n{listing}" + ) + + return float(jod) + + finally: + if temp_ctx is not None: + temp_ctx.cleanup() + + +# ------------------------------------------------------------- +# Run CVVDP: PU-PSNR-RGB2020 per frame (optional) +# ------------------------------------------------------------- + +def run_cvvdp_pu_psnr_rgb2020( + proc: subprocess.Popen, + ref_pat: Path, + test_pat: Path, + center_frame: int, + temp_window: int, + display: str, + device: str, + fps: float, + pix_per_deg: Optional[float], + verbose: bool = False +) -> Optional[float]: + k = max(0, int(temp_window)) + start = max(0, int(center_frame) - k) + end = int(center_frame) + k + + cmd = [ + "-q", + "--ffmpeg-cc", + "--device", str(device), + "--display", str(display), + "--metric", "pu-psnr-rgb2020", + "--fps", f"{float(fps):.12f}", + "--frames", f"{start}:{end}", + "--ref", str(ref_pat), + "--test", str(test_pat), + ] + if pix_per_deg is not None: + idx = cmd.index("--fps") + cmd[idx:idx] = ["--pix-per-deg", str(pix_per_deg)] + + try: + out = cvvdp_interactive_run(proc, cmd, verbose=verbose) + v = parse_metric_value(out) + return None if v is None else float(v) + except Exception as e: + if verbose: + print(f"[warn] pu-psnr-rgb2020 failed on frame {center_frame}: {e}") + return None + + +# ------------------------------------------------------------- +# Heatmap MOV + Compare MOV +# ------------------------------------------------------------- + +def encode_png_sequence_to_mov( + png_pattern: str, + fps_ffmpeg: str, + out_mov: Path, + source_is_pq: bool, + verbose: bool = False +) -> None: + """ + Stitch PNGs -> CFR ProRes MOV. + + If source_is_pq=True: + Treat heatmap PNGs as full-range RGB graphics (sRGB-ish / BT.709 primaries), + convert to linear display light, + apply 2x light scaling, + convert to PQ BT.2020, + then convert to BT.2020nc YUV for ProRes output, + and tag output as PQ/BT.2020. + """ + ffmpeg = which_or_raise("ffmpeg") + + if source_is_pq: + vf = ( + # Decode PNG RGB as RGB full-range, assume sRGB transfer + BT.709 primaries + "zscale=" + "matrixin=gbr:" + "transferin=bt709:" + "primariesin=bt709:" + "rangein=full:" + "matrix=gbr:" + "transfer=linear:" + "primaries=bt2020:" + "range=full," + # 2x scale in linear display light + "lutrgb=" + "r='clip(val*2,0,maxval)':" + "g='clip(val*2,0,maxval)':" + "b='clip(val*2,0,maxval)'," + # Linear BT.2020 RGB -> PQ BT.2020 RGB + "zscale=" + "matrixin=gbr:" + "transferin=linear:" + "primariesin=bt2020:" + "rangein=full:" + "matrix=gbr:" + "transfer=smpte2084:" + "primaries=bt2020:" + "range=full," + # PQ BT.2020 RGB -> PQ BT.2020 YUV + "zscale=" + "matrixin=gbr:" + "transferin=smpte2084:" + "primariesin=bt2020:" + "rangein=full:" + "matrix=bt2020nc:" + "transfer=smpte2084:" + "primaries=bt2020:" + "range=tv," + "setparams=" + "color_primaries=bt2020:" + "color_trc=smpte2084:" + "colorspace=bt2020nc:" + "range=tv" + ) + + run([ + ffmpeg, "-hide_banner", "-y", + "-framerate", fps_ffmpeg, + "-i", png_pattern, + "-vf", vf, + "-fps_mode", "cfr", + "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", + "-pix_fmt", "yuv422p10le", + "-movflags", "+write_colr", + "-color_primaries", "bt2020", + "-color_trc", "smpte2084", + "-colorspace", "bt2020nc", + str(out_mov) + ], verbose=verbose) + + else: + run([ + ffmpeg, "-hide_banner", "-y", + "-framerate", fps_ffmpeg, + "-i", png_pattern, + "-fps_mode", "cfr", + "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", + "-pix_fmt", "yuv422p10le", + str(out_mov) + ], verbose=verbose) + + + +def encode_compare_mov( + test: str, + heat_mov: str, + out_compare_mov: Path, + fps_ffmpeg: str, + test_meta: Dict[str, str], + verbose: bool = False +) -> None: + """ + Side-by-side compare MOV (TEST | HEATMAP MOV), CFR, no blending. + + If TEST is PQ, assume HEATMAP MOV is already PQ/BT.2020. + """ + ffmpeg = which_or_raise("ffmpeg") + _, heat_h = ffprobe_wh(heat_mov) + + pq_mode = is_pq_video(test_meta) + + if pq_mode: + filt = ( + f"[0:v]" + f"setpts=PTS-STARTPTS," + f"fps=fps={fps_ffmpeg}:round=near," + f"scale=-2:{heat_h}:flags=bicubic," + f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" + f"[v0];" + f"[1:v]" + f"setpts=PTS-STARTPTS," + f"fps=fps={fps_ffmpeg}:round=near," + f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" + f"[v1];" + f"[v0][v1]hstack=inputs=2," + f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" + f"[v]" + ) + + run([ + ffmpeg, "-hide_banner", "-y", + "-i", test, + "-i", heat_mov, + "-filter_complex", filt, + "-map", "[v]", + "-an", + "-fps_mode", "cfr", + "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", + "-pix_fmt", "yuv422p10le", + "-movflags", "+write_colr", + "-color_primaries", "bt2020", + "-color_trc", "smpte2084", + "-colorspace", "bt2020nc", + str(out_compare_mov) + ], verbose=verbose) + + else: + filt = ( + f"[0:v]setpts=PTS-STARTPTS," + f"fps=fps={fps_ffmpeg}:round=near," + f"scale=-2:{heat_h}:flags=bicubic" + f"[v0];" + f"[1:v]setpts=PTS-STARTPTS," + f"fps=fps={fps_ffmpeg}:round=near" + f"[v1];" + f"[v0][v1]hstack=inputs=2[v]" + ) + + run([ + ffmpeg, "-hide_banner", "-y", + "-i", test, + "-i", heat_mov, + "-filter_complex", filt, + "-map", "[v]", + "-an", + "-fps_mode", "cfr", + "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", + "-pix_fmt", "yuv422p10le", + str(out_compare_mov) + ], verbose=verbose) + +def cleanup_heatmaps_if_success(heat_dir: Path, heat_mov: Path) -> None: + try: + if heat_mov.exists() and heat_mov.stat().st_size > 50_000: + print(f"Heatmap MOV created successfully ({heat_mov.stat().st_size} bytes).") + print(f"Cleaning up heatmap PNG folder: {heat_dir}") + shutil.rmtree(heat_dir, ignore_errors=True) + else: + print("Heatmap MOV missing or too small — keeping heatmap PNGs for debugging.") + except Exception as e: + print(f"Cleanup warning: {e}") + + +# ------------------------------------------------------------- +# CSV legends + options block +# ------------------------------------------------------------- + +def args_to_kv_lines(args: argparse.Namespace, extra: Dict[str, str]) -> List[str]: + d = vars(args).copy() + for k, v in list(d.items()): + if v is None: + d[k] = "" + d.update(extra) + + preferred = [ + "display", "pix_per_deg", "mode", "temp_window", "device", + "pu_psnr_rgb2020", + "full_screen_resize", "display_res", + "temp_resample", "temp_padding", "dump_channels", "dump_output_dir", + "no_compare", "keep_work", "limit_frames", + "verbose", + ] + keys: List[str] = [] + for k in preferred: + if k in d: + keys.append(k) + for k in sorted(d.keys()): + if k not in keys: + keys.append(k) + + return [f"# {k}={d[k]}" for k in keys] + + +def write_csv_header_block(w: csv.writer, heatmap_mode: str, options_lines: List[str]) -> None: + w.writerow(["# CVVDP JOD legend (higher is better; typical practical range ~0–10)"]) + w.writerow(["# ~10.0 : visually indistinguishable / reference quality"]) + w.writerow(["# 9–10 : extremely high quality (differences tiny/rare)"]) + w.writerow(["# 8–9 : small but visible differences"]) + w.writerow(["# 7–8 : mild differences"]) + w.writerow(["# 5–7 : clearly noticeable degradation"]) + w.writerow(["# 3–5 : strong impairment"]) + w.writerow(["# <3 : severe distortion"]) + w.writerow([]) + + w.writerow([f"# Heatmap mode: {heatmap_mode}"]) + if heatmap_mode == "supra-threshold": + w.writerow(["# Heatmap color key (supra-threshold perceptual difference):"]) + w.writerow(["# Dark Blue : no visible difference"]) + w.writerow(["# Cyan/Green : near detection threshold"]) + w.writerow(["# Yellow : clearly visible difference"]) + w.writerow(["# Orange : strong visible difference"]) + w.writerow(["# Red : highly objectionable difference"]) + elif heatmap_mode == "threshold": + w.writerow(["# Heatmap color key (threshold detection map):"]) + w.writerow(["# Blue : below detection threshold"]) + w.writerow(["# Bright : above detection threshold"]) + elif heatmap_mode == "raw": + w.writerow(["# Heatmap color key (raw perceptual error energy):"]) + w.writerow(["# Dark : low error energy"]) + w.writerow(["# Bright : high error energy"]) + w.writerow(["# Note: heatmap visualizes spatial perceptual error under the selected display model."]) + w.writerow([]) + + w.writerow(["# Options used (including defaults):"]) + for line in options_lines: + w.writerow([line]) + w.writerow([]) + + +# ------------------------------------------------------------- +# Main +# ------------------------------------------------------------- + +def main() -> None: + ap = argparse.ArgumentParser(description="CVVDP per-frame JOD + heatmap PNG seq + MOV.") + ap.add_argument("ref", nargs="?", help="Reference video file") + ap.add_argument("test", nargs="?", help="Test video file") + ap.add_argument("outdir", nargs="?", help="Output directory") + + ap.add_argument("--mode", default="supra-threshold", choices=["supra-threshold", "threshold", "raw"]) + ap.add_argument("--display", default="NBCU_65inch_hdr_pq_2knit") + + ap.add_argument("--pix-per-deg", type=float, default=None, + help="Optional override. If omitted, display profile governs pix/deg.") + + ap.add_argument("--temp-window", type=int, default=0, + help="Temporal half-window k. Uses frames (i-k):(i+k). 0 = single-frame only.") + ap.add_argument("--device", default="mps") + + ap.add_argument("--pu-psnr-rgb2020", dest="pu_psnr_rgb2020", action="store_true", + help="Also compute per-frame pu-psnr-rgb2020 via cvvdp (--metric pu-psnr-rgb2020).") + + ap.add_argument("--full-screen-resize", default="bicubic", + choices=["bilinear", "bicubic", "nearest", "area"], + help="Applied during TIFF extraction (scale to --display-res).") + ap.add_argument("--display-res", default="3840x2160", + help="Target resolution for full-screen scaling during extraction.") + ap.add_argument("--no-compare", action="store_true") + ap.add_argument("--keep-work", action="store_true") + ap.add_argument("--limit-frames", type=int, default=0) + ap.add_argument("--verbose", action="store_true") + + ap.add_argument("--temp-resample", action="store_true", + help="Pass through cvvdp --temp-resample (mostly meaningful for video-vs-video).") + ap.add_argument("--temp-padding", default="replicate", choices=["replicate", "pingpong", "circular"], + help="Pass through cvvdp --temp-padding for temporal filters.") + ap.add_argument("--dump-channels", + nargs="+", + choices=["temporal", "lpyr", "difference"], + default=[], + help="Optional cvvdp debug dump stages to emit per frame/window.") + ap.add_argument("--dump-output-dir", + default="", + help="Optional directory to preserve cvvdp --dump-channels outputs. " + "If omitted, dump outputs are temporary.") + + args = ap.parse_args() + + if not args.ref: + args.ref = prompt_path("Reference file (drag & drop, then press Enter): ") + if not args.test: + args.test = prompt_path("Test file (drag & drop, then press Enter): ") + if not args.outdir: + args.outdir = prompt_path("Output directory (drag & drop, then press Enter): ") + + ref = clean_path(args.ref) + test = clean_path(args.test) + outdir = Path(clean_path(args.outdir)).expanduser().resolve() + outdir.mkdir(parents=True, exist_ok=True) + + display_res = parse_display_res(args.display_res) + + if args.dump_output_dir: + Path(clean_path(args.dump_output_dir)).expanduser().resolve().mkdir(parents=True, exist_ok=True) + + cvvdp_exe = find_cvvdp_exe() + print(f"\nUsing cvvdp executable: {cvvdp_exe}") + + ref_meta = ffprobe_stream_meta(ref) + test_meta = ffprobe_stream_meta(test) + + if is_pq_video(test_meta) and not ffmpeg_has_zscale(): + raise RuntimeError( + "Source video is PQ, but this ffmpeg build does not include zscale.\n" + "Install/rebuild ffmpeg with libzimg support." + ) + + fps_ffmpeg = ffprobe_r_frame_rate(test) + fps = r_frame_rate_to_float(fps_ffmpeg) + + print("\nInputs:") + print(f" Ref: {ref}") + print(f" Test: {test}") + print(f" Out: {outdir}") + print("\nffprobe (ref): ", ref_meta) + print("ffprobe (test):", test_meta) + print(f"\nUsing FPS: {fps_ffmpeg} (cvvdp fps={fps:.12f})") + print(f"CVVDP display model: {args.display}") + if args.pix_per_deg is None: + print("pix/deg override: (none; display profile governs)") + else: + print(f"pix/deg override: {args.pix_per_deg}") + print(f"temp-window K: {args.temp_window}") + print(f"pu-psnr-rgb2020: {'ON' if args.pu_psnr_rgb2020 else 'OFF'}") + print(f"full-screen-resize: {args.full_screen_resize} (applied during extraction)") + print(f"display-res: {args.display_res}") + print(f"temp-resample: {'ON' if args.temp_resample else 'OFF'}") + print(f"temp-padding: {args.temp_padding}") + print(f"dump-channels: {args.dump_channels if args.dump_channels else '(none)'}") + print(f"dump-output-dir: {args.dump_output_dir if args.dump_output_dir else '(temporary only)'}") + print("ffmpeg:", which_or_raise("ffmpeg")) + print("ffprobe:", which_or_raise("ffprobe")) + print("cvvdp :", cvvdp_exe) + print(f"PQ source detected: {is_pq_video(test_meta)}\n") + + if args.keep_work: + work_dir = Path(tempfile.mkdtemp(prefix="cvvdp_work_")) + temp_ctx = None + else: + temp_ctx = tempfile.TemporaryDirectory(prefix="cvvdp_work_") + work_dir = Path(temp_ctx.name) + + proc: Optional[subprocess.Popen] = None + + try: + ref_dir = work_dir / "ref" + test_dir = work_dir / "test" + + print("Extracting TIFF sequences (once)…") + ref_pat = extract_tiffs(ref, ref_dir, "ref", args.full_screen_resize, display_res, verbose=args.verbose) + test_pat = extract_tiffs(test, test_dir, "test", args.full_screen_resize, display_res, verbose=args.verbose) + + n_ref = count_tiffs(ref_dir, "ref") + n_test = count_tiffs(test_dir, "test") + n_frames = min(n_ref, n_test) + if n_frames <= 0: + raise RuntimeError("No TIFF frames extracted (ref or test).") + + if args.limit_frames and args.limit_frames > 0: + n_frames = min(n_frames, args.limit_frames) + + print(f"TIFF frames: ref={n_ref}, test={n_test}, using={n_frames}") + print(f"Work dir: {work_dir} {'(kept)' if args.keep_work else '(temp)'}\n") + + out_csv = outdir / "metrics_per_frame.csv" + heat_dir = outdir / f"heatmaps_{args.mode}" + heat_dir.mkdir(parents=True, exist_ok=True) + + extra_opts = { + "cvvdp_exe": cvvdp_exe, + "ref_path": ref, + "test_path": test, + "outdir": str(outdir), + "fps_ffmpeg": fps_ffmpeg, + "fps_cvvdp_float": f"{fps:.12f}", + "tiff_scale_to": f"{display_res[0]}x{display_res[1]}", + "cvvdp_mode": "interactive" if args.pu_psnr_rgb2020 else "noninteractive_for_metrics", + } + options_lines = args_to_kv_lines(args, extra_opts) + + if args.pu_psnr_rgb2020: + proc = start_cvvdp_interactive(cvvdp_exe, verbose=args.verbose) + + print(f"Building per-frame CSV: {out_csv}") + with out_csv.open("w", newline="") as f: + w = csv.writer(f) + write_csv_header_block(w, args.mode, options_lines) + + headers = [ + "frame", + "jod_total", + "time_sec", + ] + if args.pu_psnr_rgb2020: + headers += ["pu_psnr_rgb2020"] + + headers += [ + "cvvdp_display_model", + "pix_per_deg_override", + "temp_window_k", + "device", + "fps_ffmpeg", + "fps_cvvdp_float", + "ref_codec", "ref_pix_fmt", "ref_color_transfer", "ref_color_primaries", + "ref_color_space", "ref_color_range", "ref_width", "ref_height", + "test_codec", "test_pix_fmt", "test_color_transfer", "test_color_primaries", + "test_color_space", "test_color_range", "test_width", "test_height", + ] + w.writerow(headers) + f.flush() + + print(f"Generating heatmap PNGs into: {heat_dir}") + + for i in range(n_frames): + out_png = heat_dir / f"heatmap_{i:06d}.png" + + jod_total = run_cvvdp_window_and_heatmap( + cvvdp_exe=cvvdp_exe, + ref_pat=ref_pat, + test_pat=test_pat, + center_frame=i, + temp_window=args.temp_window, + display=args.display, + device=args.device, + fps=fps, + heatmap_mode=args.mode, + pix_per_deg=args.pix_per_deg, + temp_resample=args.temp_resample, + temp_padding=args.temp_padding, + dump_channels=args.dump_channels, + dump_output_dir=args.dump_output_dir, + out_png=out_png, + verbose=args.verbose + ) + + pu = None + if args.pu_psnr_rgb2020: + pu = run_cvvdp_pu_psnr_rgb2020( + proc=proc, + ref_pat=ref_pat, + test_pat=test_pat, + center_frame=i, + temp_window=args.temp_window, + display=args.display, + device=args.device, + fps=fps, + pix_per_deg=args.pix_per_deg, + verbose=args.verbose + ) + + row = [ + i, + jod_total, + f"{i / fps:.6f}", + ] + + if args.pu_psnr_rgb2020: + row.append("" if pu is None else pu) + + row += [ + args.display, + "" if args.pix_per_deg is None else str(args.pix_per_deg), + args.temp_window, + args.device, + fps_ffmpeg, + f"{fps:.12f}", + ref_meta.get("codec_name", ""), + ref_meta.get("pix_fmt", ""), + ref_meta.get("color_transfer", ""), + ref_meta.get("color_primaries", ""), + ref_meta.get("color_space", ""), + ref_meta.get("color_range", ""), + ref_meta.get("width", ""), + ref_meta.get("height", ""), + test_meta.get("codec_name", ""), + test_meta.get("pix_fmt", ""), + test_meta.get("color_transfer", ""), + test_meta.get("color_primaries", ""), + test_meta.get("color_space", ""), + test_meta.get("color_range", ""), + test_meta.get("width", ""), + test_meta.get("height", ""), + ] + + w.writerow(row) + + if (i + 1) % 10 == 0 or i == n_frames - 1: + f.flush() + print(f" heatmaps+metrics: {i+1}/{n_frames}") + + heat_mov = outdir / f"heatmap_{args.mode}.mov" + png_pattern = str(heat_dir / "heatmap_%06d.png") + print(f"\nEncoding heatmap MOV from PNGs (no blending): {heat_mov}") + + encode_png_sequence_to_mov( + png_pattern, + fps_ffmpeg, + heat_mov, + source_is_pq=is_pq_video(test_meta), + verbose=args.verbose + ) + + compare_mov = None + if not args.no_compare: + compare_mov = outdir / f"compare_test_plus_heatmap_{args.mode}.mov" + if is_pq_video(test_meta): + print("Compare MOV mode: source is PQ, auto-upmapping heatmap to PQ/BT.2020 for side-by-side") + else: + print("Compare MOV mode: source is not PQ, using standard side-by-side pipeline") + print(f"Encoding side-by-side MOV: {compare_mov}") + encode_compare_mov( + test, + str(heat_mov), + compare_mov, + fps_ffmpeg, + test_meta=test_meta, + verbose=args.verbose + ) + + cleanup_heatmaps_if_success(heat_dir, heat_mov) + + print("\nDone.") + print(f" CSV: {out_csv}") + print(f" Heatmap MOV: {heat_mov}") + if compare_mov: + print(f" Compare MOV: {compare_mov}") + if args.keep_work: + print(f" Kept workdir: {work_dir}") + + finally: + if proc is not None: + stop_cvvdp_interactive(proc) + if temp_ctx is not None: + temp_ctx.cleanup() + + +if __name__ == "__main__": + main() \ No newline at end of file From 261b685452d222db1df8a0400455ff132504c8f8 Mon Sep 17 00:00:00 2001 From: digitaltvguy Date: Fri, 6 Mar 2026 20:25:09 -0500 Subject: [PATCH 3/7] Script for per-frame JOD and heatmap Custom script to build per-frame analysis and PQ file handling for side-by-side heatmap/video comparisons --- .../cvvdp_per_frame_csv_and_heatmap_v32.3.py | 1233 +++++++++++++++++ 1 file changed, 1233 insertions(+) create mode 100755 scripting/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py diff --git a/scripting/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py b/scripting/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py new file mode 100755 index 0000000..97df151 --- /dev/null +++ b/scripting/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py @@ -0,0 +1,1233 @@ +#!/opt/homebrew/anaconda3/envs/cvvdp/bin/python +""" +cvvdp_per_frame_csv_and_heatmap_v32_2.py + +Features +-------- +• Drag-and-drop friendly (interactive prompts if paths not supplied) +• Path cleanup for macOS Terminal drag-drop (quotes, escaped spaces, CR) +• Extract REF+TEST to 16-bit RGB TIFF sequences ONCE (frame index source of truth) +• Optional full-screen resize is applied during TIFF extraction (ffmpeg scale to --display-res) + because ColorVideoVDP does NOT implement --full-screen-resize for IMAGE SEQUENCES. +• Per-frame CVVDP JOD + per-frame heatmap PNG (one PNG per source frame) + - Optional temporal window: run cvvdp on a multi-frame window (i-k):(i+k), but: + - Metric reported is for the window; we log it at the center frame index i + - Heatmap output may contain multiple frames; we extract ONLY the CENTER heatmap frame +• Optional per-frame PU-PSNR-RGB2020 (HDR-aware PSNR variant provided by cvvdp) +• Optional cvvdp debug dumps via: + --dump-channels temporal|lpyr|difference [...] + - Can optionally preserve those outputs with: + --dump-output-dir /path/to/folder +• Uses cvvdp INTERACTIVE MODE (-i) only when needed for optional PU-PSNR-RGB2020 +• Heatmap PNG sequence → ProRes MOV (CFR, no frame blending) +• Optional side-by-side compare MOV: (TEST | HEATMAP MOV) + - Automatically scales TEST to match HEATMAP height so hstack never fails + - If TEST/source is PQ, compare MOV is automatically mastered/tagged as PQ/BT.2020 + and the heatmap leg is up-mapped from SDR/BT.709 → linear display light → 2x scale + → BT.2020 → PQ so it looks correct in the HDR compare output +• Automatic PNG cleanup after successful heatmap MOV creation (keeps PNGs if MOV fails) +• CSV includes legends + options used (# key=value lines) + +Notes on temporal artifacts +--------------------------- +- If --temp-window=0 (default): CVVDP runs ONLY on frame i (no temporal measurement). +- If --temp-window>0: CVVDP runs on frames (i-k):(i+k). This allows the metric to + incorporate temporal mechanisms, BUT you must interpret the reported JOD as the + quality of that WINDOW. We log it against the center frame i. +- Heatmaps: when windowing is enabled, CVVDP may output multiple heatmap frames. + This script extracts the CENTER heatmap frame (i) so you still get exactly ONE PNG + per output frame. + +Modes +----- +--mode supra-threshold | threshold | raw + supra-threshold : perceptual supra-threshold difference map (often most useful) + threshold : detection-threshold map + raw : raw perceptual error energy map (implementation-dependent) + +USAGE: + python cvvdp_per_frame_csv_and_heatmap_v32_2.py REF.mov TEST.mov OUTDIR [options] + (or run without args and it will prompt you to drag/drop paths) + +Example: + python cvvdp_per_frame_csv_and_heatmap_v32_2.py ref.mov test.mov out \ + --display NBCU_65inch_hdr_pq_2knit --device mps --mode supra-threshold \ + --temp-window 0 --pu-psnr-rgb2020 + +IMPORTANT: +- pix/deg override is OPTIONAL. If not provided, the DISPLAY PROFILE governs pix/deg. + Use --pix-per-deg ONLY if you intentionally want to override the display model. + +ΔE-ITP +------ +Removed (prior implementation was not validated for HDR PQ/ICtCp correctness). + +--dump-channels temporal +------------------------ +Use when you’re trying to answer: “Is the metric reacting to time the way I think it is?” + +Best use-cases: + • Validate temporal-window choices (--temp-window, and in general whether motion/temporal masking is kicking in). + • Diagnose ‘temporal weirdness’: quality looks worse/better than expected during fast motion, cuts, flashes, flicker, or scrolling text. + • Debug framerate / cadence issues (e.g., accidental 23.976 vs 24 vs 59.94 conversions, duplicated frames, bad decimation). + • Sanity-check temporal padding (--temp-padding replicate|pingpong|circular) near start/end of clips when windowing is used. + +What you typically look for: + • “Temporal” intermediate outputs should show motion-related processing behaving sensibly + (not “stuck,” not exploding at cuts, not showing obvious cadence artifacts). + +--dump-channels lpyr +-------------------- +Use when you’re trying to answer: “Which spatial frequencies are triggering the score/heatmap?” + +Best use-cases: + • Find whether the ‘damage’ is high-frequency (ringing, sharpening halos, mosquito noise) vs mid/low (blur, banding, blocking). + • Compare two encodes where the JOD difference is small but you want to know why. + • Debug resize/sharpen pipelines (especially if you are scaling in ffmpeg before cvvdp by extracting TIFFs at a display-res). + +What you typically look for: + • If artifacts are mostly in high bands → expect ringing/noise; mid bands → texture loss; + low bands → luminance/contrast structure shifts. + +--dump-channels difference +-------------------------- +Use when you’re trying to answer: “What exact error field is cvvdp feeding into the final perceptual model?” + +Best use-cases: + • Explain surprising heatmaps: you see red blobs in weird places and want to know if it’s + from color conversion, luma differences, or content structure. + • Spot alignment problems (1px shifts, resample mismatch, scaling mismatch, chroma siting issues) + because the “difference” stage will often make these scream. + • Verify you’re not measuring decode/convert mistakes (wrong transfer interpretation, + wrong matrix/range assumptions) rather than actual codec differences. + +What you typically look for: + • A clean “difference” field that corresponds to real visible changes, not global offsets/tints + that imply upstream pipeline issues. + +How this ties back to optimized workflows +----------------------------------------- + • If you’re iterating on settings (temp window, scaling, framerate, padding) and need fast confidence, + --dump-channels temporal is the highest value. + • If you’re tuning encode settings (bitrate, psychovisual knobs, sharpening, grain), + --dump-channels lpyr is the best “why” tool. + • If you suspect you’re measuring the wrong thing (colorspace/TF/range mismatch, alignment), + --dump-channels difference saves the most time. +""" + +import argparse +import csv +import json +import re +import select +import shlex +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Optional, Dict, List, Tuple + +VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".m4v"} +_FLOAT_LINE_RE = re.compile(r"^\s*[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\s*$") + + +# ------------------------------------------------------------- +# Path cleaning (drag-drop safe) +# ------------------------------------------------------------- + +def clean_path(p: str) -> str: + p = p.strip().replace("\r", "") + if len(p) >= 2 and ((p[0] == '"' and p[-1] == '"') or (p[0] == "'" and p[-1] == "'")): + p = p[1:-1] + p = p.replace("\\ ", " ") + return p + + +def prompt_path(prompt: str) -> str: + sys.stdout.write(prompt) + sys.stdout.flush() + buf: List[str] = [] + while True: + ch = sys.stdin.read(1) + if ch == "": + raise RuntimeError("EOF while reading input.") + if ch in ("\n", "\r"): + line = clean_path("".join(buf)) + if line: + return line + sys.stdout.write(prompt) + sys.stdout.flush() + buf = [] + continue + buf.append(ch) + + +# ------------------------------------------------------------- +# Command runner / exec discovery +# ------------------------------------------------------------- + +def run(cmd: List[str], verbose: bool = False) -> str: + if verbose: + print(" ".join(str(c) for c in cmd)) + p = subprocess.run( + cmd, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True + ) + if p.returncode != 0: + raise RuntimeError(f"Command failed ({p.returncode}): {' '.join(map(str, cmd))}\n\n{p.stdout}") + return p.stdout + + +def which_or_raise(name: str) -> str: + p = shutil.which(name) + if not p: + raise RuntimeError(f"Required executable not found on PATH: {name}") + return p + + +def find_cvvdp_exe() -> str: + p = shutil.which("cvvdp") + if p: + return p + + pybin = Path(sys.executable).resolve().parent + cand = pybin / "cvvdp" + if cand.exists(): + return str(cand) + + conda_prefix = Path(sys.executable).resolve().parent.parent + cand2 = conda_prefix / "bin" / "cvvdp" + if cand2.exists(): + return str(cand2) + + candidates = [ + Path("/opt/homebrew/anaconda3/envs"), + Path("/opt/homebrew/Caskroom/miniconda/base/envs"), + Path.home() / "miniconda3" / "envs", + Path.home() / "anaconda3" / "envs", + Path("/usr/local/anaconda3/envs"), + Path("/usr/local/miniconda3/envs"), + ] + for root in candidates: + if root.exists(): + for exe in root.glob("*/bin/cvvdp"): + if exe.exists(): + return str(exe) + + raise RuntimeError( + "Could not locate 'cvvdp' executable.\n" + f"sys.executable = {sys.executable}\n" + "Fix options:\n" + " A) Run with env python, e.g.: /opt/homebrew/anaconda3/envs/cvvdp/bin/python script.py\n" + " B) Put env on PATH, e.g.: export PATH=/opt/homebrew/anaconda3/envs/cvvdp/bin:$PATH\n" + " C) Or hardcode CVVDP_EXE in this script.\n" + ) + + +# ------------------------------------------------------------- +# ffprobe helpers +# ------------------------------------------------------------- + +def ffprobe_stream_meta(path: str) -> Dict[str, str]: + ffprobe = which_or_raise("ffprobe") + out = run([ + ffprobe, "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=codec_name,pix_fmt,color_space,color_transfer,color_primaries,bit_rate,width,height,r_frame_rate,color_range", + "-of", "json", + path + ]) + j = json.loads(out) + s = (j.get("streams") or [{}])[0] + + def g(k: str) -> str: + v = s.get(k, "") + return "" if v is None else str(v) + + return { + "codec_name": g("codec_name"), + "pix_fmt": g("pix_fmt"), + "color_space": g("color_space"), + "color_transfer": g("color_transfer"), + "color_primaries": g("color_primaries"), + "color_range": g("color_range"), + "bit_rate": g("bit_rate"), + "width": g("width"), + "height": g("height"), + "r_frame_rate": g("r_frame_rate"), + } + + +def ffprobe_r_frame_rate(path: str) -> str: + r = ffprobe_stream_meta(path).get("r_frame_rate", "") + if not r: + raise RuntimeError("ffprobe did not return r_frame_rate.") + return r + + +def r_frame_rate_to_float(r: str) -> float: + if "/" in r: + n, d = r.split("/") + return float(n) / float(d) + return float(r) + + +def ffprobe_wh(path: str) -> Tuple[int, int]: + ffprobe = which_or_raise("ffprobe") + out = run([ + ffprobe, "-v", "error", + "-select_streams", "v:0", + "-show_entries", "stream=width,height", + "-of", "csv=p=0:s=x", + path + ]) + s = out.strip().splitlines()[0] + w, h = s.split("x") + return int(w), int(h) + + +def is_pq_video(meta: Dict[str, str]) -> bool: + trc = (meta.get("color_transfer") or "").strip().lower() + return trc in {"smpte2084", "pq"} + + +def ffmpeg_has_zscale() -> bool: + ffmpeg = which_or_raise("ffmpeg") + try: + out = run([ffmpeg, "-hide_banner", "-filters"]) + for line in out.splitlines(): + parts = line.split() + if len(parts) >= 2 and parts[1] == "zscale": + return True + return False + except Exception: + return False + + +# ------------------------------------------------------------- +# Display-res parsing / scaling flags +# ------------------------------------------------------------- + +def parse_display_res(res: str) -> Tuple[int, int]: + s = res.lower().replace(" ", "") + if "x" not in s: + raise ValueError(f"--display-res must be like 3840x2160, got: {res}") + w, h = s.split("x", 1) + return int(w), int(h) + + +def ffmpeg_scale_flags(full_screen_resize: str) -> str: + m = { + "bilinear": "bilinear", + "bicubic": "bicubic", + "nearest": "neighbor", + "area": "area", + } + return m[full_screen_resize] + + +# ------------------------------------------------------------- +# Frame extraction (TIFF) +# ------------------------------------------------------------- + +def extract_tiffs(video: str, + out_dir: Path, + prefix: str, + full_screen_resize: str, + display_res: Tuple[int, int], + verbose: bool = False) -> Path: + ffmpeg = which_or_raise("ffmpeg") + out_dir.mkdir(parents=True, exist_ok=True) + pattern = out_dir / f"{prefix}_%06d.tif" + + w, h = display_res + flags = ffmpeg_scale_flags(full_screen_resize) + vf = f"scale={w}:{h}:flags={flags}" + + run([ + ffmpeg, "-hide_banner", "-y", + "-i", video, + "-vsync", "0", + "-start_number", "0", + "-vf", vf, + "-pix_fmt", "rgb48le", + str(pattern) + ], verbose=verbose) + + return pattern + + +def count_tiffs(out_dir: Path, prefix: str) -> int: + return len(list(out_dir.glob(f"{prefix}_*.tif"))) + + +# ------------------------------------------------------------- +# CVVDP parsing helpers +# ------------------------------------------------------------- + +def parse_metric_value(output: str) -> Optional[float]: + s = output.strip() + if not s: + return None + + if "\n" not in s: + try: + return float(s) + except Exception: + pass + + for line in s.splitlines(): + t = line.strip() + if "[JOD]" in t and "=" in t: + try: + return float(t.split("=", 1)[1].split()[0]) + except Exception: + continue + + for line in s.splitlines(): + t = line.strip() + if "=" in t: + try: + return float(t.split("=", 1)[1].split()[0]) + except Exception: + pass + else: + try: + return float(t.split()[0]) + except Exception: + pass + + return None + + +# ------------------------------------------------------------- +# Interactive cvvdp helpers +# ------------------------------------------------------------- + +def _join_tokens_for_interactive(tokens: List[str]) -> str: + return " ".join(shlex.quote(t) for t in tokens) + + +def start_cvvdp_interactive(cvvdp_exe: str, verbose: bool = False) -> subprocess.Popen: + cmd = [cvvdp_exe, "-i"] + if verbose: + print("Starting cvvdp interactive:", " ".join(cmd)) + p = subprocess.Popen( + cmd, + stdin=subprocess.PIPE, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, + ) + assert p.stdin and p.stdout + return p + + +def _read_one_result_block(proc: subprocess.Popen, timeout_sec: float = 180.0) -> str: + assert proc.stdout + t0 = time.time() + lines: List[str] = [] + + while True: + if time.time() - t0 > timeout_sec: + raise RuntimeError("cvvdp interactive read timed out") + + line = proc.stdout.readline() + if line == "": + raise RuntimeError("cvvdp interactive process ended unexpectedly") + + lines.append(line) + s = line.strip() + + if _FLOAT_LINE_RE.match(s) or (s.startswith("cvvdp") and "=" in s): + end_t = time.time() + 0.05 + while time.time() < end_t: + r, _, _ = select.select([proc.stdout], [], [], 0.0) + if not r: + break + more = proc.stdout.readline() + if more == "": + break + lines.append(more) + return "".join(lines) + + +def cvvdp_interactive_run(proc: subprocess.Popen, tokens: List[str], verbose: bool = False) -> str: + assert proc is not None + assert proc.stdin and proc.stdout + line = _join_tokens_for_interactive(tokens) + "\n" + if verbose: + print("cvvdp(i) <=", line.strip()) + proc.stdin.write(line) + proc.stdin.flush() + out = _read_one_result_block(proc) + if verbose: + print("cvvdp(i) =>", out.strip()) + return out + + +def stop_cvvdp_interactive(proc: subprocess.Popen) -> None: + try: + if proc.stdin: + proc.stdin.close() + except Exception: + pass + try: + proc.terminate() + except Exception: + pass + try: + proc.wait(timeout=2.0) + except Exception: + try: + proc.kill() + except Exception: + pass + + +# ------------------------------------------------------------- +# Heatmap extraction helpers +# ------------------------------------------------------------- + +def _extract_center_frame_from_video(src_video: Path, out_png: Path, center_index: int, verbose: bool = False) -> None: + ffmpeg = which_or_raise("ffmpeg") + out_png.parent.mkdir(parents=True, exist_ok=True) + vf = f"select='eq(n\\,{center_index})'" + run([ + ffmpeg, "-hide_banner", "-y", + "-i", str(src_video), + "-vf", vf, + "-frames:v", "1", + str(out_png) + ], verbose=verbose) + + +def _extract_single_png_or_convert(src_img: Path, out_png: Path, verbose: bool = False) -> None: + ffmpeg = which_or_raise("ffmpeg") + out_png.parent.mkdir(parents=True, exist_ok=True) + if src_img.suffix.lower() == ".png": + out_png.write_bytes(src_img.read_bytes()) + else: + run([ + ffmpeg, "-hide_banner", "-y", + "-i", str(src_img), + "-frames:v", "1", + str(out_png) + ], verbose=verbose) + + +# ------------------------------------------------------------- +# Run CVVDP: JOD + HEATMAP (window) → center-frame heatmap PNG +# ------------------------------------------------------------- + +def run_cvvdp_window_and_heatmap( + cvvdp_exe: str, + ref_pat: Path, + test_pat: Path, + center_frame: int, + temp_window: int, + display: str, + device: str, + fps: float, + heatmap_mode: str, + pix_per_deg: Optional[float], + temp_resample: bool, + temp_padding: str, + dump_channels: List[str], + dump_output_dir: str, + out_png: Path, + verbose: bool = False +) -> float: + k = max(0, int(temp_window)) + start = max(0, int(center_frame) - k) + end = int(center_frame) + k + + temp_ctx = None + try: + if dump_channels and dump_output_dir: + td_path = Path(dump_output_dir).expanduser().resolve() / f"frame_{center_frame:06d}" + if td_path.exists(): + shutil.rmtree(td_path) + td_path.mkdir(parents=True, exist_ok=True) + else: + temp_ctx = tempfile.TemporaryDirectory(prefix="cvvdp_win_") + td_path = Path(temp_ctx.name) + + cmd = [ + str(cvvdp_exe), + "--ffmpeg-cc", + "--device", str(device), + "--display", str(display), + "--fps", f"{float(fps):.12f}", + "--frames", f"{start}:{end}", + "--heatmap", str(heatmap_mode), + "--output-dir", str(td_path), + "--ref", str(ref_pat), + "--test", str(test_pat), + ] + + if temp_resample: + cmd.insert(1, "--temp-resample") + + if temp_padding: + cmd.insert(1, temp_padding) + cmd.insert(1, "--temp-padding") + + if dump_channels: + idx = cmd.index("--output-dir") + cmd[idx:idx] = ["--dump-channels", *dump_channels] + + if pix_per_deg is not None: + idx = cmd.index("--fps") + cmd[idx:idx] = ["--pix-per-deg", str(pix_per_deg)] + + out = run(cmd, verbose=verbose) + + jod = parse_metric_value(out) + if jod is None: + raise RuntimeError( + f"Could not parse JOD for center_frame={center_frame}\n--- cvvdp output ---\n{out}" + ) + + heat_imgs = sorted([ + p for p in td_path.iterdir() + if p.is_file() + and "heatmap" in p.name.lower() + and p.suffix.lower() in {".png", ".tif", ".tiff"} + ]) + heat_vids = sorted([ + p for p in td_path.iterdir() + if p.is_file() + and "heatmap" in p.name.lower() + and p.suffix.lower() in VIDEO_EXTS + ]) + + if heat_imgs: + _extract_single_png_or_convert(heat_imgs[0], out_png, verbose=verbose) + elif heat_vids: + center_idx = max(0, int(center_frame) - start) + _extract_center_frame_from_video(heat_vids[0], out_png, center_idx, verbose=verbose) + else: + listing = "\n".join([p.name for p in td_path.iterdir()]) + raise RuntimeError( + f"No heatmap output produced for center_frame={center_frame}.\nDir:\n{listing}" + ) + + return float(jod) + + finally: + if temp_ctx is not None: + temp_ctx.cleanup() + + +# ------------------------------------------------------------- +# Run CVVDP: PU-PSNR-RGB2020 per frame (optional) +# ------------------------------------------------------------- + +def run_cvvdp_pu_psnr_rgb2020( + proc: subprocess.Popen, + ref_pat: Path, + test_pat: Path, + center_frame: int, + temp_window: int, + display: str, + device: str, + fps: float, + pix_per_deg: Optional[float], + verbose: bool = False +) -> Optional[float]: + k = max(0, int(temp_window)) + start = max(0, int(center_frame) - k) + end = int(center_frame) + k + + cmd = [ + "-q", + "--ffmpeg-cc", + "--device", str(device), + "--display", str(display), + "--metric", "pu-psnr-rgb2020", + "--fps", f"{float(fps):.12f}", + "--frames", f"{start}:{end}", + "--ref", str(ref_pat), + "--test", str(test_pat), + ] + if pix_per_deg is not None: + idx = cmd.index("--fps") + cmd[idx:idx] = ["--pix-per-deg", str(pix_per_deg)] + + try: + out = cvvdp_interactive_run(proc, cmd, verbose=verbose) + v = parse_metric_value(out) + return None if v is None else float(v) + except Exception as e: + if verbose: + print(f"[warn] pu-psnr-rgb2020 failed on frame {center_frame}: {e}") + return None + + +# ------------------------------------------------------------- +# Heatmap MOV + Compare MOV +# ------------------------------------------------------------- + +def encode_png_sequence_to_mov( + png_pattern: str, + fps_ffmpeg: str, + out_mov: Path, + source_is_pq: bool, + verbose: bool = False +) -> None: + """ + Stitch PNGs -> CFR ProRes MOV. + + If source_is_pq=True: + Treat heatmap PNGs as full-range RGB graphics (sRGB-ish / BT.709 primaries), + convert to linear display light, + apply 2x light scaling, + convert to PQ BT.2020, + then convert to BT.2020nc YUV for ProRes output, + and tag output as PQ/BT.2020. + """ + ffmpeg = which_or_raise("ffmpeg") + + if source_is_pq: + vf = ( + # Decode PNG RGB as RGB full-range, assume sRGB transfer + BT.709 primaries + "zscale=" + "matrixin=gbr:" + "transferin=bt709:" + "primariesin=bt709:" + "rangein=full:" + "matrix=gbr:" + "transfer=linear:" + "primaries=bt2020:" + "range=full," + # 2x scale in linear display light + "lutrgb=" + "r='clip(val*2,0,maxval)':" + "g='clip(val*2,0,maxval)':" + "b='clip(val*2,0,maxval)'," + # Linear BT.2020 RGB -> PQ BT.2020 RGB + "zscale=" + "matrixin=gbr:" + "transferin=linear:" + "primariesin=bt2020:" + "rangein=full:" + "matrix=gbr:" + "transfer=smpte2084:" + "primaries=bt2020:" + "range=full," + # PQ BT.2020 RGB -> PQ BT.2020 YUV + "zscale=" + "matrixin=gbr:" + "transferin=smpte2084:" + "primariesin=bt2020:" + "rangein=full:" + "matrix=bt2020nc:" + "transfer=smpte2084:" + "primaries=bt2020:" + "range=tv," + "setparams=" + "color_primaries=bt2020:" + "color_trc=smpte2084:" + "colorspace=bt2020nc:" + "range=tv" + ) + + run([ + ffmpeg, "-hide_banner", "-y", + "-framerate", fps_ffmpeg, + "-i", png_pattern, + "-vf", vf, + "-fps_mode", "cfr", + "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", + "-pix_fmt", "yuv422p10le", + "-movflags", "+write_colr", + "-color_primaries", "bt2020", + "-color_trc", "smpte2084", + "-colorspace", "bt2020nc", + str(out_mov) + ], verbose=verbose) + + else: + run([ + ffmpeg, "-hide_banner", "-y", + "-framerate", fps_ffmpeg, + "-i", png_pattern, + "-fps_mode", "cfr", + "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", + "-pix_fmt", "yuv422p10le", + str(out_mov) + ], verbose=verbose) + + + +def encode_compare_mov( + test: str, + heat_mov: str, + out_compare_mov: Path, + fps_ffmpeg: str, + test_meta: Dict[str, str], + verbose: bool = False +) -> None: + """ + Side-by-side compare MOV (TEST | HEATMAP MOV), CFR, no blending. + + If TEST is PQ, assume HEATMAP MOV is already PQ/BT.2020. + """ + ffmpeg = which_or_raise("ffmpeg") + _, heat_h = ffprobe_wh(heat_mov) + + pq_mode = is_pq_video(test_meta) + + if pq_mode: + filt = ( + f"[0:v]" + f"setpts=PTS-STARTPTS," + f"fps=fps={fps_ffmpeg}:round=near," + f"scale=-2:{heat_h}:flags=bicubic," + f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" + f"[v0];" + f"[1:v]" + f"setpts=PTS-STARTPTS," + f"fps=fps={fps_ffmpeg}:round=near," + f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" + f"[v1];" + f"[v0][v1]hstack=inputs=2," + f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" + f"[v]" + ) + + run([ + ffmpeg, "-hide_banner", "-y", + "-i", test, + "-i", heat_mov, + "-filter_complex", filt, + "-map", "[v]", + "-an", + "-fps_mode", "cfr", + "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", + "-pix_fmt", "yuv422p10le", + "-movflags", "+write_colr", + "-color_primaries", "bt2020", + "-color_trc", "smpte2084", + "-colorspace", "bt2020nc", + str(out_compare_mov) + ], verbose=verbose) + + else: + filt = ( + f"[0:v]setpts=PTS-STARTPTS," + f"fps=fps={fps_ffmpeg}:round=near," + f"scale=-2:{heat_h}:flags=bicubic" + f"[v0];" + f"[1:v]setpts=PTS-STARTPTS," + f"fps=fps={fps_ffmpeg}:round=near" + f"[v1];" + f"[v0][v1]hstack=inputs=2[v]" + ) + + run([ + ffmpeg, "-hide_banner", "-y", + "-i", test, + "-i", heat_mov, + "-filter_complex", filt, + "-map", "[v]", + "-an", + "-fps_mode", "cfr", + "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", + "-pix_fmt", "yuv422p10le", + str(out_compare_mov) + ], verbose=verbose) + +def cleanup_heatmaps_if_success(heat_dir: Path, heat_mov: Path) -> None: + try: + if heat_mov.exists() and heat_mov.stat().st_size > 50_000: + print(f"Heatmap MOV created successfully ({heat_mov.stat().st_size} bytes).") + print(f"Cleaning up heatmap PNG folder: {heat_dir}") + shutil.rmtree(heat_dir, ignore_errors=True) + else: + print("Heatmap MOV missing or too small — keeping heatmap PNGs for debugging.") + except Exception as e: + print(f"Cleanup warning: {e}") + + +# ------------------------------------------------------------- +# CSV legends + options block +# ------------------------------------------------------------- + +def args_to_kv_lines(args: argparse.Namespace, extra: Dict[str, str]) -> List[str]: + d = vars(args).copy() + for k, v in list(d.items()): + if v is None: + d[k] = "" + d.update(extra) + + preferred = [ + "display", "pix_per_deg", "mode", "temp_window", "device", + "pu_psnr_rgb2020", + "full_screen_resize", "display_res", + "temp_resample", "temp_padding", "dump_channels", "dump_output_dir", + "no_compare", "keep_work", "limit_frames", + "verbose", + ] + keys: List[str] = [] + for k in preferred: + if k in d: + keys.append(k) + for k in sorted(d.keys()): + if k not in keys: + keys.append(k) + + return [f"# {k}={d[k]}" for k in keys] + + +def write_csv_header_block(w: csv.writer, heatmap_mode: str, options_lines: List[str]) -> None: + w.writerow(["# CVVDP JOD legend (higher is better; typical practical range ~0–10)"]) + w.writerow(["# ~10.0 : visually indistinguishable / reference quality"]) + w.writerow(["# 9–10 : extremely high quality (differences tiny/rare)"]) + w.writerow(["# 8–9 : small but visible differences"]) + w.writerow(["# 7–8 : mild differences"]) + w.writerow(["# 5–7 : clearly noticeable degradation"]) + w.writerow(["# 3–5 : strong impairment"]) + w.writerow(["# <3 : severe distortion"]) + w.writerow([]) + + w.writerow([f"# Heatmap mode: {heatmap_mode}"]) + if heatmap_mode == "supra-threshold": + w.writerow(["# Heatmap color key (supra-threshold perceptual difference):"]) + w.writerow(["# Dark Blue : no visible difference"]) + w.writerow(["# Cyan/Green : near detection threshold"]) + w.writerow(["# Yellow : clearly visible difference"]) + w.writerow(["# Orange : strong visible difference"]) + w.writerow(["# Red : highly objectionable difference"]) + elif heatmap_mode == "threshold": + w.writerow(["# Heatmap color key (threshold detection map):"]) + w.writerow(["# Blue : below detection threshold"]) + w.writerow(["# Bright : above detection threshold"]) + elif heatmap_mode == "raw": + w.writerow(["# Heatmap color key (raw perceptual error energy):"]) + w.writerow(["# Dark : low error energy"]) + w.writerow(["# Bright : high error energy"]) + w.writerow(["# Note: heatmap visualizes spatial perceptual error under the selected display model."]) + w.writerow([]) + + w.writerow(["# Options used (including defaults):"]) + for line in options_lines: + w.writerow([line]) + w.writerow([]) + + +# ------------------------------------------------------------- +# Main +# ------------------------------------------------------------- + +def main() -> None: + ap = argparse.ArgumentParser(description="CVVDP per-frame JOD + heatmap PNG seq + MOV.") + ap.add_argument("ref", nargs="?", help="Reference video file") + ap.add_argument("test", nargs="?", help="Test video file") + ap.add_argument("outdir", nargs="?", help="Output directory") + + ap.add_argument("--mode", default="supra-threshold", choices=["supra-threshold", "threshold", "raw"]) + ap.add_argument("--display", default="NBCU_65inch_hdr_pq_2knit") + + ap.add_argument("--pix-per-deg", type=float, default=None, + help="Optional override. If omitted, display profile governs pix/deg.") + + ap.add_argument("--temp-window", type=int, default=0, + help="Temporal half-window k. Uses frames (i-k):(i+k). 0 = single-frame only.") + ap.add_argument("--device", default="mps") + + ap.add_argument("--pu-psnr-rgb2020", dest="pu_psnr_rgb2020", action="store_true", + help="Also compute per-frame pu-psnr-rgb2020 via cvvdp (--metric pu-psnr-rgb2020).") + + ap.add_argument("--full-screen-resize", default="bicubic", + choices=["bilinear", "bicubic", "nearest", "area"], + help="Applied during TIFF extraction (scale to --display-res).") + ap.add_argument("--display-res", default="3840x2160", + help="Target resolution for full-screen scaling during extraction.") + ap.add_argument("--no-compare", action="store_true") + ap.add_argument("--keep-work", action="store_true") + ap.add_argument("--limit-frames", type=int, default=0) + ap.add_argument("--verbose", action="store_true") + + ap.add_argument("--temp-resample", action="store_true", + help="Pass through cvvdp --temp-resample (mostly meaningful for video-vs-video).") + ap.add_argument("--temp-padding", default="replicate", choices=["replicate", "pingpong", "circular"], + help="Pass through cvvdp --temp-padding for temporal filters.") + ap.add_argument("--dump-channels", + nargs="+", + choices=["temporal", "lpyr", "difference"], + default=[], + help="Optional cvvdp debug dump stages to emit per frame/window.") + ap.add_argument("--dump-output-dir", + default="", + help="Optional directory to preserve cvvdp --dump-channels outputs. " + "If omitted, dump outputs are temporary.") + + args = ap.parse_args() + + if not args.ref: + args.ref = prompt_path("Reference file (drag & drop, then press Enter): ") + if not args.test: + args.test = prompt_path("Test file (drag & drop, then press Enter): ") + if not args.outdir: + args.outdir = prompt_path("Output directory (drag & drop, then press Enter): ") + + ref = clean_path(args.ref) + test = clean_path(args.test) + outdir = Path(clean_path(args.outdir)).expanduser().resolve() + outdir.mkdir(parents=True, exist_ok=True) + + display_res = parse_display_res(args.display_res) + + if args.dump_output_dir: + Path(clean_path(args.dump_output_dir)).expanduser().resolve().mkdir(parents=True, exist_ok=True) + + cvvdp_exe = find_cvvdp_exe() + print(f"\nUsing cvvdp executable: {cvvdp_exe}") + + ref_meta = ffprobe_stream_meta(ref) + test_meta = ffprobe_stream_meta(test) + + if is_pq_video(test_meta) and not ffmpeg_has_zscale(): + raise RuntimeError( + "Source video is PQ, but this ffmpeg build does not include zscale.\n" + "Install/rebuild ffmpeg with libzimg support." + ) + + fps_ffmpeg = ffprobe_r_frame_rate(test) + fps = r_frame_rate_to_float(fps_ffmpeg) + + print("\nInputs:") + print(f" Ref: {ref}") + print(f" Test: {test}") + print(f" Out: {outdir}") + print("\nffprobe (ref): ", ref_meta) + print("ffprobe (test):", test_meta) + print(f"\nUsing FPS: {fps_ffmpeg} (cvvdp fps={fps:.12f})") + print(f"CVVDP display model: {args.display}") + if args.pix_per_deg is None: + print("pix/deg override: (none; display profile governs)") + else: + print(f"pix/deg override: {args.pix_per_deg}") + print(f"temp-window K: {args.temp_window}") + print(f"pu-psnr-rgb2020: {'ON' if args.pu_psnr_rgb2020 else 'OFF'}") + print(f"full-screen-resize: {args.full_screen_resize} (applied during extraction)") + print(f"display-res: {args.display_res}") + print(f"temp-resample: {'ON' if args.temp_resample else 'OFF'}") + print(f"temp-padding: {args.temp_padding}") + print(f"dump-channels: {args.dump_channels if args.dump_channels else '(none)'}") + print(f"dump-output-dir: {args.dump_output_dir if args.dump_output_dir else '(temporary only)'}") + print("ffmpeg:", which_or_raise("ffmpeg")) + print("ffprobe:", which_or_raise("ffprobe")) + print("cvvdp :", cvvdp_exe) + print(f"PQ source detected: {is_pq_video(test_meta)}\n") + + if args.keep_work: + work_dir = Path(tempfile.mkdtemp(prefix="cvvdp_work_")) + temp_ctx = None + else: + temp_ctx = tempfile.TemporaryDirectory(prefix="cvvdp_work_") + work_dir = Path(temp_ctx.name) + + proc: Optional[subprocess.Popen] = None + + try: + ref_dir = work_dir / "ref" + test_dir = work_dir / "test" + + print("Extracting TIFF sequences (once)…") + ref_pat = extract_tiffs(ref, ref_dir, "ref", args.full_screen_resize, display_res, verbose=args.verbose) + test_pat = extract_tiffs(test, test_dir, "test", args.full_screen_resize, display_res, verbose=args.verbose) + + n_ref = count_tiffs(ref_dir, "ref") + n_test = count_tiffs(test_dir, "test") + n_frames = min(n_ref, n_test) + if n_frames <= 0: + raise RuntimeError("No TIFF frames extracted (ref or test).") + + if args.limit_frames and args.limit_frames > 0: + n_frames = min(n_frames, args.limit_frames) + + print(f"TIFF frames: ref={n_ref}, test={n_test}, using={n_frames}") + print(f"Work dir: {work_dir} {'(kept)' if args.keep_work else '(temp)'}\n") + + out_csv = outdir / "metrics_per_frame.csv" + heat_dir = outdir / f"heatmaps_{args.mode}" + heat_dir.mkdir(parents=True, exist_ok=True) + + extra_opts = { + "cvvdp_exe": cvvdp_exe, + "ref_path": ref, + "test_path": test, + "outdir": str(outdir), + "fps_ffmpeg": fps_ffmpeg, + "fps_cvvdp_float": f"{fps:.12f}", + "tiff_scale_to": f"{display_res[0]}x{display_res[1]}", + "cvvdp_mode": "interactive" if args.pu_psnr_rgb2020 else "noninteractive_for_metrics", + } + options_lines = args_to_kv_lines(args, extra_opts) + + if args.pu_psnr_rgb2020: + proc = start_cvvdp_interactive(cvvdp_exe, verbose=args.verbose) + + print(f"Building per-frame CSV: {out_csv}") + with out_csv.open("w", newline="") as f: + w = csv.writer(f) + write_csv_header_block(w, args.mode, options_lines) + + headers = [ + "frame", + "jod_total", + "time_sec", + ] + if args.pu_psnr_rgb2020: + headers += ["pu_psnr_rgb2020"] + + headers += [ + "cvvdp_display_model", + "pix_per_deg_override", + "temp_window_k", + "device", + "fps_ffmpeg", + "fps_cvvdp_float", + "ref_codec", "ref_pix_fmt", "ref_color_transfer", "ref_color_primaries", + "ref_color_space", "ref_color_range", "ref_width", "ref_height", + "test_codec", "test_pix_fmt", "test_color_transfer", "test_color_primaries", + "test_color_space", "test_color_range", "test_width", "test_height", + ] + w.writerow(headers) + f.flush() + + print(f"Generating heatmap PNGs into: {heat_dir}") + + for i in range(n_frames): + out_png = heat_dir / f"heatmap_{i:06d}.png" + + jod_total = run_cvvdp_window_and_heatmap( + cvvdp_exe=cvvdp_exe, + ref_pat=ref_pat, + test_pat=test_pat, + center_frame=i, + temp_window=args.temp_window, + display=args.display, + device=args.device, + fps=fps, + heatmap_mode=args.mode, + pix_per_deg=args.pix_per_deg, + temp_resample=args.temp_resample, + temp_padding=args.temp_padding, + dump_channels=args.dump_channels, + dump_output_dir=args.dump_output_dir, + out_png=out_png, + verbose=args.verbose + ) + + pu = None + if args.pu_psnr_rgb2020: + pu = run_cvvdp_pu_psnr_rgb2020( + proc=proc, + ref_pat=ref_pat, + test_pat=test_pat, + center_frame=i, + temp_window=args.temp_window, + display=args.display, + device=args.device, + fps=fps, + pix_per_deg=args.pix_per_deg, + verbose=args.verbose + ) + + row = [ + i, + jod_total, + f"{i / fps:.6f}", + ] + + if args.pu_psnr_rgb2020: + row.append("" if pu is None else pu) + + row += [ + args.display, + "" if args.pix_per_deg is None else str(args.pix_per_deg), + args.temp_window, + args.device, + fps_ffmpeg, + f"{fps:.12f}", + ref_meta.get("codec_name", ""), + ref_meta.get("pix_fmt", ""), + ref_meta.get("color_transfer", ""), + ref_meta.get("color_primaries", ""), + ref_meta.get("color_space", ""), + ref_meta.get("color_range", ""), + ref_meta.get("width", ""), + ref_meta.get("height", ""), + test_meta.get("codec_name", ""), + test_meta.get("pix_fmt", ""), + test_meta.get("color_transfer", ""), + test_meta.get("color_primaries", ""), + test_meta.get("color_space", ""), + test_meta.get("color_range", ""), + test_meta.get("width", ""), + test_meta.get("height", ""), + ] + + w.writerow(row) + + if (i + 1) % 10 == 0 or i == n_frames - 1: + f.flush() + print(f" heatmaps+metrics: {i+1}/{n_frames}") + + heat_mov = outdir / f"heatmap_{args.mode}.mov" + png_pattern = str(heat_dir / "heatmap_%06d.png") + print(f"\nEncoding heatmap MOV from PNGs (no blending): {heat_mov}") + + encode_png_sequence_to_mov( + png_pattern, + fps_ffmpeg, + heat_mov, + source_is_pq=is_pq_video(test_meta), + verbose=args.verbose + ) + + compare_mov = None + if not args.no_compare: + compare_mov = outdir / f"compare_test_plus_heatmap_{args.mode}.mov" + if is_pq_video(test_meta): + print("Compare MOV mode: source is PQ, auto-upmapping heatmap to PQ/BT.2020 for side-by-side") + else: + print("Compare MOV mode: source is not PQ, using standard side-by-side pipeline") + print(f"Encoding side-by-side MOV: {compare_mov}") + encode_compare_mov( + test, + str(heat_mov), + compare_mov, + fps_ffmpeg, + test_meta=test_meta, + verbose=args.verbose + ) + + cleanup_heatmaps_if_success(heat_dir, heat_mov) + + print("\nDone.") + print(f" CSV: {out_csv}") + print(f" Heatmap MOV: {heat_mov}") + if compare_mov: + print(f" Compare MOV: {compare_mov}") + if args.keep_work: + print(f" Kept workdir: {work_dir}") + + finally: + if proc is not None: + stop_cvvdp_interactive(proc) + if temp_ctx is not None: + temp_ctx.cleanup() + + +if __name__ == "__main__": + main() \ No newline at end of file From 99f48dd1e1ce26eaef6d6c0f7f110e1346332a8c Mon Sep 17 00:00:00 2001 From: digitaltvguy Date: Fri, 6 Mar 2026 20:26:04 -0500 Subject: [PATCH 4/7] Delete cvvdp_per_frame_csv_and_heatmap_v32.3.py --- .../cvvdp_per_frame_csv_and_heatmap_v32.3.py | 1233 ----------------- 1 file changed, 1233 deletions(-) delete mode 100644 macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py diff --git a/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py b/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py deleted file mode 100644 index 97df151..0000000 --- a/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py +++ /dev/null @@ -1,1233 +0,0 @@ -#!/opt/homebrew/anaconda3/envs/cvvdp/bin/python -""" -cvvdp_per_frame_csv_and_heatmap_v32_2.py - -Features --------- -• Drag-and-drop friendly (interactive prompts if paths not supplied) -• Path cleanup for macOS Terminal drag-drop (quotes, escaped spaces, CR) -• Extract REF+TEST to 16-bit RGB TIFF sequences ONCE (frame index source of truth) -• Optional full-screen resize is applied during TIFF extraction (ffmpeg scale to --display-res) - because ColorVideoVDP does NOT implement --full-screen-resize for IMAGE SEQUENCES. -• Per-frame CVVDP JOD + per-frame heatmap PNG (one PNG per source frame) - - Optional temporal window: run cvvdp on a multi-frame window (i-k):(i+k), but: - - Metric reported is for the window; we log it at the center frame index i - - Heatmap output may contain multiple frames; we extract ONLY the CENTER heatmap frame -• Optional per-frame PU-PSNR-RGB2020 (HDR-aware PSNR variant provided by cvvdp) -• Optional cvvdp debug dumps via: - --dump-channels temporal|lpyr|difference [...] - - Can optionally preserve those outputs with: - --dump-output-dir /path/to/folder -• Uses cvvdp INTERACTIVE MODE (-i) only when needed for optional PU-PSNR-RGB2020 -• Heatmap PNG sequence → ProRes MOV (CFR, no frame blending) -• Optional side-by-side compare MOV: (TEST | HEATMAP MOV) - - Automatically scales TEST to match HEATMAP height so hstack never fails - - If TEST/source is PQ, compare MOV is automatically mastered/tagged as PQ/BT.2020 - and the heatmap leg is up-mapped from SDR/BT.709 → linear display light → 2x scale - → BT.2020 → PQ so it looks correct in the HDR compare output -• Automatic PNG cleanup after successful heatmap MOV creation (keeps PNGs if MOV fails) -• CSV includes legends + options used (# key=value lines) - -Notes on temporal artifacts ---------------------------- -- If --temp-window=0 (default): CVVDP runs ONLY on frame i (no temporal measurement). -- If --temp-window>0: CVVDP runs on frames (i-k):(i+k). This allows the metric to - incorporate temporal mechanisms, BUT you must interpret the reported JOD as the - quality of that WINDOW. We log it against the center frame i. -- Heatmaps: when windowing is enabled, CVVDP may output multiple heatmap frames. - This script extracts the CENTER heatmap frame (i) so you still get exactly ONE PNG - per output frame. - -Modes ------ ---mode supra-threshold | threshold | raw - supra-threshold : perceptual supra-threshold difference map (often most useful) - threshold : detection-threshold map - raw : raw perceptual error energy map (implementation-dependent) - -USAGE: - python cvvdp_per_frame_csv_and_heatmap_v32_2.py REF.mov TEST.mov OUTDIR [options] - (or run without args and it will prompt you to drag/drop paths) - -Example: - python cvvdp_per_frame_csv_and_heatmap_v32_2.py ref.mov test.mov out \ - --display NBCU_65inch_hdr_pq_2knit --device mps --mode supra-threshold \ - --temp-window 0 --pu-psnr-rgb2020 - -IMPORTANT: -- pix/deg override is OPTIONAL. If not provided, the DISPLAY PROFILE governs pix/deg. - Use --pix-per-deg ONLY if you intentionally want to override the display model. - -ΔE-ITP ------- -Removed (prior implementation was not validated for HDR PQ/ICtCp correctness). - ---dump-channels temporal ------------------------- -Use when you’re trying to answer: “Is the metric reacting to time the way I think it is?” - -Best use-cases: - • Validate temporal-window choices (--temp-window, and in general whether motion/temporal masking is kicking in). - • Diagnose ‘temporal weirdness’: quality looks worse/better than expected during fast motion, cuts, flashes, flicker, or scrolling text. - • Debug framerate / cadence issues (e.g., accidental 23.976 vs 24 vs 59.94 conversions, duplicated frames, bad decimation). - • Sanity-check temporal padding (--temp-padding replicate|pingpong|circular) near start/end of clips when windowing is used. - -What you typically look for: - • “Temporal” intermediate outputs should show motion-related processing behaving sensibly - (not “stuck,” not exploding at cuts, not showing obvious cadence artifacts). - ---dump-channels lpyr --------------------- -Use when you’re trying to answer: “Which spatial frequencies are triggering the score/heatmap?” - -Best use-cases: - • Find whether the ‘damage’ is high-frequency (ringing, sharpening halos, mosquito noise) vs mid/low (blur, banding, blocking). - • Compare two encodes where the JOD difference is small but you want to know why. - • Debug resize/sharpen pipelines (especially if you are scaling in ffmpeg before cvvdp by extracting TIFFs at a display-res). - -What you typically look for: - • If artifacts are mostly in high bands → expect ringing/noise; mid bands → texture loss; - low bands → luminance/contrast structure shifts. - ---dump-channels difference --------------------------- -Use when you’re trying to answer: “What exact error field is cvvdp feeding into the final perceptual model?” - -Best use-cases: - • Explain surprising heatmaps: you see red blobs in weird places and want to know if it’s - from color conversion, luma differences, or content structure. - • Spot alignment problems (1px shifts, resample mismatch, scaling mismatch, chroma siting issues) - because the “difference” stage will often make these scream. - • Verify you’re not measuring decode/convert mistakes (wrong transfer interpretation, - wrong matrix/range assumptions) rather than actual codec differences. - -What you typically look for: - • A clean “difference” field that corresponds to real visible changes, not global offsets/tints - that imply upstream pipeline issues. - -How this ties back to optimized workflows ------------------------------------------ - • If you’re iterating on settings (temp window, scaling, framerate, padding) and need fast confidence, - --dump-channels temporal is the highest value. - • If you’re tuning encode settings (bitrate, psychovisual knobs, sharpening, grain), - --dump-channels lpyr is the best “why” tool. - • If you suspect you’re measuring the wrong thing (colorspace/TF/range mismatch, alignment), - --dump-channels difference saves the most time. -""" - -import argparse -import csv -import json -import re -import select -import shlex -import shutil -import subprocess -import sys -import tempfile -import time -from pathlib import Path -from typing import Optional, Dict, List, Tuple - -VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".m4v"} -_FLOAT_LINE_RE = re.compile(r"^\s*[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\s*$") - - -# ------------------------------------------------------------- -# Path cleaning (drag-drop safe) -# ------------------------------------------------------------- - -def clean_path(p: str) -> str: - p = p.strip().replace("\r", "") - if len(p) >= 2 and ((p[0] == '"' and p[-1] == '"') or (p[0] == "'" and p[-1] == "'")): - p = p[1:-1] - p = p.replace("\\ ", " ") - return p - - -def prompt_path(prompt: str) -> str: - sys.stdout.write(prompt) - sys.stdout.flush() - buf: List[str] = [] - while True: - ch = sys.stdin.read(1) - if ch == "": - raise RuntimeError("EOF while reading input.") - if ch in ("\n", "\r"): - line = clean_path("".join(buf)) - if line: - return line - sys.stdout.write(prompt) - sys.stdout.flush() - buf = [] - continue - buf.append(ch) - - -# ------------------------------------------------------------- -# Command runner / exec discovery -# ------------------------------------------------------------- - -def run(cmd: List[str], verbose: bool = False) -> str: - if verbose: - print(" ".join(str(c) for c in cmd)) - p = subprocess.run( - cmd, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True - ) - if p.returncode != 0: - raise RuntimeError(f"Command failed ({p.returncode}): {' '.join(map(str, cmd))}\n\n{p.stdout}") - return p.stdout - - -def which_or_raise(name: str) -> str: - p = shutil.which(name) - if not p: - raise RuntimeError(f"Required executable not found on PATH: {name}") - return p - - -def find_cvvdp_exe() -> str: - p = shutil.which("cvvdp") - if p: - return p - - pybin = Path(sys.executable).resolve().parent - cand = pybin / "cvvdp" - if cand.exists(): - return str(cand) - - conda_prefix = Path(sys.executable).resolve().parent.parent - cand2 = conda_prefix / "bin" / "cvvdp" - if cand2.exists(): - return str(cand2) - - candidates = [ - Path("/opt/homebrew/anaconda3/envs"), - Path("/opt/homebrew/Caskroom/miniconda/base/envs"), - Path.home() / "miniconda3" / "envs", - Path.home() / "anaconda3" / "envs", - Path("/usr/local/anaconda3/envs"), - Path("/usr/local/miniconda3/envs"), - ] - for root in candidates: - if root.exists(): - for exe in root.glob("*/bin/cvvdp"): - if exe.exists(): - return str(exe) - - raise RuntimeError( - "Could not locate 'cvvdp' executable.\n" - f"sys.executable = {sys.executable}\n" - "Fix options:\n" - " A) Run with env python, e.g.: /opt/homebrew/anaconda3/envs/cvvdp/bin/python script.py\n" - " B) Put env on PATH, e.g.: export PATH=/opt/homebrew/anaconda3/envs/cvvdp/bin:$PATH\n" - " C) Or hardcode CVVDP_EXE in this script.\n" - ) - - -# ------------------------------------------------------------- -# ffprobe helpers -# ------------------------------------------------------------- - -def ffprobe_stream_meta(path: str) -> Dict[str, str]: - ffprobe = which_or_raise("ffprobe") - out = run([ - ffprobe, "-v", "error", - "-select_streams", "v:0", - "-show_entries", "stream=codec_name,pix_fmt,color_space,color_transfer,color_primaries,bit_rate,width,height,r_frame_rate,color_range", - "-of", "json", - path - ]) - j = json.loads(out) - s = (j.get("streams") or [{}])[0] - - def g(k: str) -> str: - v = s.get(k, "") - return "" if v is None else str(v) - - return { - "codec_name": g("codec_name"), - "pix_fmt": g("pix_fmt"), - "color_space": g("color_space"), - "color_transfer": g("color_transfer"), - "color_primaries": g("color_primaries"), - "color_range": g("color_range"), - "bit_rate": g("bit_rate"), - "width": g("width"), - "height": g("height"), - "r_frame_rate": g("r_frame_rate"), - } - - -def ffprobe_r_frame_rate(path: str) -> str: - r = ffprobe_stream_meta(path).get("r_frame_rate", "") - if not r: - raise RuntimeError("ffprobe did not return r_frame_rate.") - return r - - -def r_frame_rate_to_float(r: str) -> float: - if "/" in r: - n, d = r.split("/") - return float(n) / float(d) - return float(r) - - -def ffprobe_wh(path: str) -> Tuple[int, int]: - ffprobe = which_or_raise("ffprobe") - out = run([ - ffprobe, "-v", "error", - "-select_streams", "v:0", - "-show_entries", "stream=width,height", - "-of", "csv=p=0:s=x", - path - ]) - s = out.strip().splitlines()[0] - w, h = s.split("x") - return int(w), int(h) - - -def is_pq_video(meta: Dict[str, str]) -> bool: - trc = (meta.get("color_transfer") or "").strip().lower() - return trc in {"smpte2084", "pq"} - - -def ffmpeg_has_zscale() -> bool: - ffmpeg = which_or_raise("ffmpeg") - try: - out = run([ffmpeg, "-hide_banner", "-filters"]) - for line in out.splitlines(): - parts = line.split() - if len(parts) >= 2 and parts[1] == "zscale": - return True - return False - except Exception: - return False - - -# ------------------------------------------------------------- -# Display-res parsing / scaling flags -# ------------------------------------------------------------- - -def parse_display_res(res: str) -> Tuple[int, int]: - s = res.lower().replace(" ", "") - if "x" not in s: - raise ValueError(f"--display-res must be like 3840x2160, got: {res}") - w, h = s.split("x", 1) - return int(w), int(h) - - -def ffmpeg_scale_flags(full_screen_resize: str) -> str: - m = { - "bilinear": "bilinear", - "bicubic": "bicubic", - "nearest": "neighbor", - "area": "area", - } - return m[full_screen_resize] - - -# ------------------------------------------------------------- -# Frame extraction (TIFF) -# ------------------------------------------------------------- - -def extract_tiffs(video: str, - out_dir: Path, - prefix: str, - full_screen_resize: str, - display_res: Tuple[int, int], - verbose: bool = False) -> Path: - ffmpeg = which_or_raise("ffmpeg") - out_dir.mkdir(parents=True, exist_ok=True) - pattern = out_dir / f"{prefix}_%06d.tif" - - w, h = display_res - flags = ffmpeg_scale_flags(full_screen_resize) - vf = f"scale={w}:{h}:flags={flags}" - - run([ - ffmpeg, "-hide_banner", "-y", - "-i", video, - "-vsync", "0", - "-start_number", "0", - "-vf", vf, - "-pix_fmt", "rgb48le", - str(pattern) - ], verbose=verbose) - - return pattern - - -def count_tiffs(out_dir: Path, prefix: str) -> int: - return len(list(out_dir.glob(f"{prefix}_*.tif"))) - - -# ------------------------------------------------------------- -# CVVDP parsing helpers -# ------------------------------------------------------------- - -def parse_metric_value(output: str) -> Optional[float]: - s = output.strip() - if not s: - return None - - if "\n" not in s: - try: - return float(s) - except Exception: - pass - - for line in s.splitlines(): - t = line.strip() - if "[JOD]" in t and "=" in t: - try: - return float(t.split("=", 1)[1].split()[0]) - except Exception: - continue - - for line in s.splitlines(): - t = line.strip() - if "=" in t: - try: - return float(t.split("=", 1)[1].split()[0]) - except Exception: - pass - else: - try: - return float(t.split()[0]) - except Exception: - pass - - return None - - -# ------------------------------------------------------------- -# Interactive cvvdp helpers -# ------------------------------------------------------------- - -def _join_tokens_for_interactive(tokens: List[str]) -> str: - return " ".join(shlex.quote(t) for t in tokens) - - -def start_cvvdp_interactive(cvvdp_exe: str, verbose: bool = False) -> subprocess.Popen: - cmd = [cvvdp_exe, "-i"] - if verbose: - print("Starting cvvdp interactive:", " ".join(cmd)) - p = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - assert p.stdin and p.stdout - return p - - -def _read_one_result_block(proc: subprocess.Popen, timeout_sec: float = 180.0) -> str: - assert proc.stdout - t0 = time.time() - lines: List[str] = [] - - while True: - if time.time() - t0 > timeout_sec: - raise RuntimeError("cvvdp interactive read timed out") - - line = proc.stdout.readline() - if line == "": - raise RuntimeError("cvvdp interactive process ended unexpectedly") - - lines.append(line) - s = line.strip() - - if _FLOAT_LINE_RE.match(s) or (s.startswith("cvvdp") and "=" in s): - end_t = time.time() + 0.05 - while time.time() < end_t: - r, _, _ = select.select([proc.stdout], [], [], 0.0) - if not r: - break - more = proc.stdout.readline() - if more == "": - break - lines.append(more) - return "".join(lines) - - -def cvvdp_interactive_run(proc: subprocess.Popen, tokens: List[str], verbose: bool = False) -> str: - assert proc is not None - assert proc.stdin and proc.stdout - line = _join_tokens_for_interactive(tokens) + "\n" - if verbose: - print("cvvdp(i) <=", line.strip()) - proc.stdin.write(line) - proc.stdin.flush() - out = _read_one_result_block(proc) - if verbose: - print("cvvdp(i) =>", out.strip()) - return out - - -def stop_cvvdp_interactive(proc: subprocess.Popen) -> None: - try: - if proc.stdin: - proc.stdin.close() - except Exception: - pass - try: - proc.terminate() - except Exception: - pass - try: - proc.wait(timeout=2.0) - except Exception: - try: - proc.kill() - except Exception: - pass - - -# ------------------------------------------------------------- -# Heatmap extraction helpers -# ------------------------------------------------------------- - -def _extract_center_frame_from_video(src_video: Path, out_png: Path, center_index: int, verbose: bool = False) -> None: - ffmpeg = which_or_raise("ffmpeg") - out_png.parent.mkdir(parents=True, exist_ok=True) - vf = f"select='eq(n\\,{center_index})'" - run([ - ffmpeg, "-hide_banner", "-y", - "-i", str(src_video), - "-vf", vf, - "-frames:v", "1", - str(out_png) - ], verbose=verbose) - - -def _extract_single_png_or_convert(src_img: Path, out_png: Path, verbose: bool = False) -> None: - ffmpeg = which_or_raise("ffmpeg") - out_png.parent.mkdir(parents=True, exist_ok=True) - if src_img.suffix.lower() == ".png": - out_png.write_bytes(src_img.read_bytes()) - else: - run([ - ffmpeg, "-hide_banner", "-y", - "-i", str(src_img), - "-frames:v", "1", - str(out_png) - ], verbose=verbose) - - -# ------------------------------------------------------------- -# Run CVVDP: JOD + HEATMAP (window) → center-frame heatmap PNG -# ------------------------------------------------------------- - -def run_cvvdp_window_and_heatmap( - cvvdp_exe: str, - ref_pat: Path, - test_pat: Path, - center_frame: int, - temp_window: int, - display: str, - device: str, - fps: float, - heatmap_mode: str, - pix_per_deg: Optional[float], - temp_resample: bool, - temp_padding: str, - dump_channels: List[str], - dump_output_dir: str, - out_png: Path, - verbose: bool = False -) -> float: - k = max(0, int(temp_window)) - start = max(0, int(center_frame) - k) - end = int(center_frame) + k - - temp_ctx = None - try: - if dump_channels and dump_output_dir: - td_path = Path(dump_output_dir).expanduser().resolve() / f"frame_{center_frame:06d}" - if td_path.exists(): - shutil.rmtree(td_path) - td_path.mkdir(parents=True, exist_ok=True) - else: - temp_ctx = tempfile.TemporaryDirectory(prefix="cvvdp_win_") - td_path = Path(temp_ctx.name) - - cmd = [ - str(cvvdp_exe), - "--ffmpeg-cc", - "--device", str(device), - "--display", str(display), - "--fps", f"{float(fps):.12f}", - "--frames", f"{start}:{end}", - "--heatmap", str(heatmap_mode), - "--output-dir", str(td_path), - "--ref", str(ref_pat), - "--test", str(test_pat), - ] - - if temp_resample: - cmd.insert(1, "--temp-resample") - - if temp_padding: - cmd.insert(1, temp_padding) - cmd.insert(1, "--temp-padding") - - if dump_channels: - idx = cmd.index("--output-dir") - cmd[idx:idx] = ["--dump-channels", *dump_channels] - - if pix_per_deg is not None: - idx = cmd.index("--fps") - cmd[idx:idx] = ["--pix-per-deg", str(pix_per_deg)] - - out = run(cmd, verbose=verbose) - - jod = parse_metric_value(out) - if jod is None: - raise RuntimeError( - f"Could not parse JOD for center_frame={center_frame}\n--- cvvdp output ---\n{out}" - ) - - heat_imgs = sorted([ - p for p in td_path.iterdir() - if p.is_file() - and "heatmap" in p.name.lower() - and p.suffix.lower() in {".png", ".tif", ".tiff"} - ]) - heat_vids = sorted([ - p for p in td_path.iterdir() - if p.is_file() - and "heatmap" in p.name.lower() - and p.suffix.lower() in VIDEO_EXTS - ]) - - if heat_imgs: - _extract_single_png_or_convert(heat_imgs[0], out_png, verbose=verbose) - elif heat_vids: - center_idx = max(0, int(center_frame) - start) - _extract_center_frame_from_video(heat_vids[0], out_png, center_idx, verbose=verbose) - else: - listing = "\n".join([p.name for p in td_path.iterdir()]) - raise RuntimeError( - f"No heatmap output produced for center_frame={center_frame}.\nDir:\n{listing}" - ) - - return float(jod) - - finally: - if temp_ctx is not None: - temp_ctx.cleanup() - - -# ------------------------------------------------------------- -# Run CVVDP: PU-PSNR-RGB2020 per frame (optional) -# ------------------------------------------------------------- - -def run_cvvdp_pu_psnr_rgb2020( - proc: subprocess.Popen, - ref_pat: Path, - test_pat: Path, - center_frame: int, - temp_window: int, - display: str, - device: str, - fps: float, - pix_per_deg: Optional[float], - verbose: bool = False -) -> Optional[float]: - k = max(0, int(temp_window)) - start = max(0, int(center_frame) - k) - end = int(center_frame) + k - - cmd = [ - "-q", - "--ffmpeg-cc", - "--device", str(device), - "--display", str(display), - "--metric", "pu-psnr-rgb2020", - "--fps", f"{float(fps):.12f}", - "--frames", f"{start}:{end}", - "--ref", str(ref_pat), - "--test", str(test_pat), - ] - if pix_per_deg is not None: - idx = cmd.index("--fps") - cmd[idx:idx] = ["--pix-per-deg", str(pix_per_deg)] - - try: - out = cvvdp_interactive_run(proc, cmd, verbose=verbose) - v = parse_metric_value(out) - return None if v is None else float(v) - except Exception as e: - if verbose: - print(f"[warn] pu-psnr-rgb2020 failed on frame {center_frame}: {e}") - return None - - -# ------------------------------------------------------------- -# Heatmap MOV + Compare MOV -# ------------------------------------------------------------- - -def encode_png_sequence_to_mov( - png_pattern: str, - fps_ffmpeg: str, - out_mov: Path, - source_is_pq: bool, - verbose: bool = False -) -> None: - """ - Stitch PNGs -> CFR ProRes MOV. - - If source_is_pq=True: - Treat heatmap PNGs as full-range RGB graphics (sRGB-ish / BT.709 primaries), - convert to linear display light, - apply 2x light scaling, - convert to PQ BT.2020, - then convert to BT.2020nc YUV for ProRes output, - and tag output as PQ/BT.2020. - """ - ffmpeg = which_or_raise("ffmpeg") - - if source_is_pq: - vf = ( - # Decode PNG RGB as RGB full-range, assume sRGB transfer + BT.709 primaries - "zscale=" - "matrixin=gbr:" - "transferin=bt709:" - "primariesin=bt709:" - "rangein=full:" - "matrix=gbr:" - "transfer=linear:" - "primaries=bt2020:" - "range=full," - # 2x scale in linear display light - "lutrgb=" - "r='clip(val*2,0,maxval)':" - "g='clip(val*2,0,maxval)':" - "b='clip(val*2,0,maxval)'," - # Linear BT.2020 RGB -> PQ BT.2020 RGB - "zscale=" - "matrixin=gbr:" - "transferin=linear:" - "primariesin=bt2020:" - "rangein=full:" - "matrix=gbr:" - "transfer=smpte2084:" - "primaries=bt2020:" - "range=full," - # PQ BT.2020 RGB -> PQ BT.2020 YUV - "zscale=" - "matrixin=gbr:" - "transferin=smpte2084:" - "primariesin=bt2020:" - "rangein=full:" - "matrix=bt2020nc:" - "transfer=smpte2084:" - "primaries=bt2020:" - "range=tv," - "setparams=" - "color_primaries=bt2020:" - "color_trc=smpte2084:" - "colorspace=bt2020nc:" - "range=tv" - ) - - run([ - ffmpeg, "-hide_banner", "-y", - "-framerate", fps_ffmpeg, - "-i", png_pattern, - "-vf", vf, - "-fps_mode", "cfr", - "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", - "-pix_fmt", "yuv422p10le", - "-movflags", "+write_colr", - "-color_primaries", "bt2020", - "-color_trc", "smpte2084", - "-colorspace", "bt2020nc", - str(out_mov) - ], verbose=verbose) - - else: - run([ - ffmpeg, "-hide_banner", "-y", - "-framerate", fps_ffmpeg, - "-i", png_pattern, - "-fps_mode", "cfr", - "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", - "-pix_fmt", "yuv422p10le", - str(out_mov) - ], verbose=verbose) - - - -def encode_compare_mov( - test: str, - heat_mov: str, - out_compare_mov: Path, - fps_ffmpeg: str, - test_meta: Dict[str, str], - verbose: bool = False -) -> None: - """ - Side-by-side compare MOV (TEST | HEATMAP MOV), CFR, no blending. - - If TEST is PQ, assume HEATMAP MOV is already PQ/BT.2020. - """ - ffmpeg = which_or_raise("ffmpeg") - _, heat_h = ffprobe_wh(heat_mov) - - pq_mode = is_pq_video(test_meta) - - if pq_mode: - filt = ( - f"[0:v]" - f"setpts=PTS-STARTPTS," - f"fps=fps={fps_ffmpeg}:round=near," - f"scale=-2:{heat_h}:flags=bicubic," - f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" - f"[v0];" - f"[1:v]" - f"setpts=PTS-STARTPTS," - f"fps=fps={fps_ffmpeg}:round=near," - f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" - f"[v1];" - f"[v0][v1]hstack=inputs=2," - f"setparams=color_primaries=bt2020:color_trc=smpte2084:colorspace=bt2020nc:range=tv" - f"[v]" - ) - - run([ - ffmpeg, "-hide_banner", "-y", - "-i", test, - "-i", heat_mov, - "-filter_complex", filt, - "-map", "[v]", - "-an", - "-fps_mode", "cfr", - "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", - "-pix_fmt", "yuv422p10le", - "-movflags", "+write_colr", - "-color_primaries", "bt2020", - "-color_trc", "smpte2084", - "-colorspace", "bt2020nc", - str(out_compare_mov) - ], verbose=verbose) - - else: - filt = ( - f"[0:v]setpts=PTS-STARTPTS," - f"fps=fps={fps_ffmpeg}:round=near," - f"scale=-2:{heat_h}:flags=bicubic" - f"[v0];" - f"[1:v]setpts=PTS-STARTPTS," - f"fps=fps={fps_ffmpeg}:round=near" - f"[v1];" - f"[v0][v1]hstack=inputs=2[v]" - ) - - run([ - ffmpeg, "-hide_banner", "-y", - "-i", test, - "-i", heat_mov, - "-filter_complex", filt, - "-map", "[v]", - "-an", - "-fps_mode", "cfr", - "-c:v", "prores_ks", "-profile:v", "3", "-q:v", "7", - "-pix_fmt", "yuv422p10le", - str(out_compare_mov) - ], verbose=verbose) - -def cleanup_heatmaps_if_success(heat_dir: Path, heat_mov: Path) -> None: - try: - if heat_mov.exists() and heat_mov.stat().st_size > 50_000: - print(f"Heatmap MOV created successfully ({heat_mov.stat().st_size} bytes).") - print(f"Cleaning up heatmap PNG folder: {heat_dir}") - shutil.rmtree(heat_dir, ignore_errors=True) - else: - print("Heatmap MOV missing or too small — keeping heatmap PNGs for debugging.") - except Exception as e: - print(f"Cleanup warning: {e}") - - -# ------------------------------------------------------------- -# CSV legends + options block -# ------------------------------------------------------------- - -def args_to_kv_lines(args: argparse.Namespace, extra: Dict[str, str]) -> List[str]: - d = vars(args).copy() - for k, v in list(d.items()): - if v is None: - d[k] = "" - d.update(extra) - - preferred = [ - "display", "pix_per_deg", "mode", "temp_window", "device", - "pu_psnr_rgb2020", - "full_screen_resize", "display_res", - "temp_resample", "temp_padding", "dump_channels", "dump_output_dir", - "no_compare", "keep_work", "limit_frames", - "verbose", - ] - keys: List[str] = [] - for k in preferred: - if k in d: - keys.append(k) - for k in sorted(d.keys()): - if k not in keys: - keys.append(k) - - return [f"# {k}={d[k]}" for k in keys] - - -def write_csv_header_block(w: csv.writer, heatmap_mode: str, options_lines: List[str]) -> None: - w.writerow(["# CVVDP JOD legend (higher is better; typical practical range ~0–10)"]) - w.writerow(["# ~10.0 : visually indistinguishable / reference quality"]) - w.writerow(["# 9–10 : extremely high quality (differences tiny/rare)"]) - w.writerow(["# 8–9 : small but visible differences"]) - w.writerow(["# 7–8 : mild differences"]) - w.writerow(["# 5–7 : clearly noticeable degradation"]) - w.writerow(["# 3–5 : strong impairment"]) - w.writerow(["# <3 : severe distortion"]) - w.writerow([]) - - w.writerow([f"# Heatmap mode: {heatmap_mode}"]) - if heatmap_mode == "supra-threshold": - w.writerow(["# Heatmap color key (supra-threshold perceptual difference):"]) - w.writerow(["# Dark Blue : no visible difference"]) - w.writerow(["# Cyan/Green : near detection threshold"]) - w.writerow(["# Yellow : clearly visible difference"]) - w.writerow(["# Orange : strong visible difference"]) - w.writerow(["# Red : highly objectionable difference"]) - elif heatmap_mode == "threshold": - w.writerow(["# Heatmap color key (threshold detection map):"]) - w.writerow(["# Blue : below detection threshold"]) - w.writerow(["# Bright : above detection threshold"]) - elif heatmap_mode == "raw": - w.writerow(["# Heatmap color key (raw perceptual error energy):"]) - w.writerow(["# Dark : low error energy"]) - w.writerow(["# Bright : high error energy"]) - w.writerow(["# Note: heatmap visualizes spatial perceptual error under the selected display model."]) - w.writerow([]) - - w.writerow(["# Options used (including defaults):"]) - for line in options_lines: - w.writerow([line]) - w.writerow([]) - - -# ------------------------------------------------------------- -# Main -# ------------------------------------------------------------- - -def main() -> None: - ap = argparse.ArgumentParser(description="CVVDP per-frame JOD + heatmap PNG seq + MOV.") - ap.add_argument("ref", nargs="?", help="Reference video file") - ap.add_argument("test", nargs="?", help="Test video file") - ap.add_argument("outdir", nargs="?", help="Output directory") - - ap.add_argument("--mode", default="supra-threshold", choices=["supra-threshold", "threshold", "raw"]) - ap.add_argument("--display", default="NBCU_65inch_hdr_pq_2knit") - - ap.add_argument("--pix-per-deg", type=float, default=None, - help="Optional override. If omitted, display profile governs pix/deg.") - - ap.add_argument("--temp-window", type=int, default=0, - help="Temporal half-window k. Uses frames (i-k):(i+k). 0 = single-frame only.") - ap.add_argument("--device", default="mps") - - ap.add_argument("--pu-psnr-rgb2020", dest="pu_psnr_rgb2020", action="store_true", - help="Also compute per-frame pu-psnr-rgb2020 via cvvdp (--metric pu-psnr-rgb2020).") - - ap.add_argument("--full-screen-resize", default="bicubic", - choices=["bilinear", "bicubic", "nearest", "area"], - help="Applied during TIFF extraction (scale to --display-res).") - ap.add_argument("--display-res", default="3840x2160", - help="Target resolution for full-screen scaling during extraction.") - ap.add_argument("--no-compare", action="store_true") - ap.add_argument("--keep-work", action="store_true") - ap.add_argument("--limit-frames", type=int, default=0) - ap.add_argument("--verbose", action="store_true") - - ap.add_argument("--temp-resample", action="store_true", - help="Pass through cvvdp --temp-resample (mostly meaningful for video-vs-video).") - ap.add_argument("--temp-padding", default="replicate", choices=["replicate", "pingpong", "circular"], - help="Pass through cvvdp --temp-padding for temporal filters.") - ap.add_argument("--dump-channels", - nargs="+", - choices=["temporal", "lpyr", "difference"], - default=[], - help="Optional cvvdp debug dump stages to emit per frame/window.") - ap.add_argument("--dump-output-dir", - default="", - help="Optional directory to preserve cvvdp --dump-channels outputs. " - "If omitted, dump outputs are temporary.") - - args = ap.parse_args() - - if not args.ref: - args.ref = prompt_path("Reference file (drag & drop, then press Enter): ") - if not args.test: - args.test = prompt_path("Test file (drag & drop, then press Enter): ") - if not args.outdir: - args.outdir = prompt_path("Output directory (drag & drop, then press Enter): ") - - ref = clean_path(args.ref) - test = clean_path(args.test) - outdir = Path(clean_path(args.outdir)).expanduser().resolve() - outdir.mkdir(parents=True, exist_ok=True) - - display_res = parse_display_res(args.display_res) - - if args.dump_output_dir: - Path(clean_path(args.dump_output_dir)).expanduser().resolve().mkdir(parents=True, exist_ok=True) - - cvvdp_exe = find_cvvdp_exe() - print(f"\nUsing cvvdp executable: {cvvdp_exe}") - - ref_meta = ffprobe_stream_meta(ref) - test_meta = ffprobe_stream_meta(test) - - if is_pq_video(test_meta) and not ffmpeg_has_zscale(): - raise RuntimeError( - "Source video is PQ, but this ffmpeg build does not include zscale.\n" - "Install/rebuild ffmpeg with libzimg support." - ) - - fps_ffmpeg = ffprobe_r_frame_rate(test) - fps = r_frame_rate_to_float(fps_ffmpeg) - - print("\nInputs:") - print(f" Ref: {ref}") - print(f" Test: {test}") - print(f" Out: {outdir}") - print("\nffprobe (ref): ", ref_meta) - print("ffprobe (test):", test_meta) - print(f"\nUsing FPS: {fps_ffmpeg} (cvvdp fps={fps:.12f})") - print(f"CVVDP display model: {args.display}") - if args.pix_per_deg is None: - print("pix/deg override: (none; display profile governs)") - else: - print(f"pix/deg override: {args.pix_per_deg}") - print(f"temp-window K: {args.temp_window}") - print(f"pu-psnr-rgb2020: {'ON' if args.pu_psnr_rgb2020 else 'OFF'}") - print(f"full-screen-resize: {args.full_screen_resize} (applied during extraction)") - print(f"display-res: {args.display_res}") - print(f"temp-resample: {'ON' if args.temp_resample else 'OFF'}") - print(f"temp-padding: {args.temp_padding}") - print(f"dump-channels: {args.dump_channels if args.dump_channels else '(none)'}") - print(f"dump-output-dir: {args.dump_output_dir if args.dump_output_dir else '(temporary only)'}") - print("ffmpeg:", which_or_raise("ffmpeg")) - print("ffprobe:", which_or_raise("ffprobe")) - print("cvvdp :", cvvdp_exe) - print(f"PQ source detected: {is_pq_video(test_meta)}\n") - - if args.keep_work: - work_dir = Path(tempfile.mkdtemp(prefix="cvvdp_work_")) - temp_ctx = None - else: - temp_ctx = tempfile.TemporaryDirectory(prefix="cvvdp_work_") - work_dir = Path(temp_ctx.name) - - proc: Optional[subprocess.Popen] = None - - try: - ref_dir = work_dir / "ref" - test_dir = work_dir / "test" - - print("Extracting TIFF sequences (once)…") - ref_pat = extract_tiffs(ref, ref_dir, "ref", args.full_screen_resize, display_res, verbose=args.verbose) - test_pat = extract_tiffs(test, test_dir, "test", args.full_screen_resize, display_res, verbose=args.verbose) - - n_ref = count_tiffs(ref_dir, "ref") - n_test = count_tiffs(test_dir, "test") - n_frames = min(n_ref, n_test) - if n_frames <= 0: - raise RuntimeError("No TIFF frames extracted (ref or test).") - - if args.limit_frames and args.limit_frames > 0: - n_frames = min(n_frames, args.limit_frames) - - print(f"TIFF frames: ref={n_ref}, test={n_test}, using={n_frames}") - print(f"Work dir: {work_dir} {'(kept)' if args.keep_work else '(temp)'}\n") - - out_csv = outdir / "metrics_per_frame.csv" - heat_dir = outdir / f"heatmaps_{args.mode}" - heat_dir.mkdir(parents=True, exist_ok=True) - - extra_opts = { - "cvvdp_exe": cvvdp_exe, - "ref_path": ref, - "test_path": test, - "outdir": str(outdir), - "fps_ffmpeg": fps_ffmpeg, - "fps_cvvdp_float": f"{fps:.12f}", - "tiff_scale_to": f"{display_res[0]}x{display_res[1]}", - "cvvdp_mode": "interactive" if args.pu_psnr_rgb2020 else "noninteractive_for_metrics", - } - options_lines = args_to_kv_lines(args, extra_opts) - - if args.pu_psnr_rgb2020: - proc = start_cvvdp_interactive(cvvdp_exe, verbose=args.verbose) - - print(f"Building per-frame CSV: {out_csv}") - with out_csv.open("w", newline="") as f: - w = csv.writer(f) - write_csv_header_block(w, args.mode, options_lines) - - headers = [ - "frame", - "jod_total", - "time_sec", - ] - if args.pu_psnr_rgb2020: - headers += ["pu_psnr_rgb2020"] - - headers += [ - "cvvdp_display_model", - "pix_per_deg_override", - "temp_window_k", - "device", - "fps_ffmpeg", - "fps_cvvdp_float", - "ref_codec", "ref_pix_fmt", "ref_color_transfer", "ref_color_primaries", - "ref_color_space", "ref_color_range", "ref_width", "ref_height", - "test_codec", "test_pix_fmt", "test_color_transfer", "test_color_primaries", - "test_color_space", "test_color_range", "test_width", "test_height", - ] - w.writerow(headers) - f.flush() - - print(f"Generating heatmap PNGs into: {heat_dir}") - - for i in range(n_frames): - out_png = heat_dir / f"heatmap_{i:06d}.png" - - jod_total = run_cvvdp_window_and_heatmap( - cvvdp_exe=cvvdp_exe, - ref_pat=ref_pat, - test_pat=test_pat, - center_frame=i, - temp_window=args.temp_window, - display=args.display, - device=args.device, - fps=fps, - heatmap_mode=args.mode, - pix_per_deg=args.pix_per_deg, - temp_resample=args.temp_resample, - temp_padding=args.temp_padding, - dump_channels=args.dump_channels, - dump_output_dir=args.dump_output_dir, - out_png=out_png, - verbose=args.verbose - ) - - pu = None - if args.pu_psnr_rgb2020: - pu = run_cvvdp_pu_psnr_rgb2020( - proc=proc, - ref_pat=ref_pat, - test_pat=test_pat, - center_frame=i, - temp_window=args.temp_window, - display=args.display, - device=args.device, - fps=fps, - pix_per_deg=args.pix_per_deg, - verbose=args.verbose - ) - - row = [ - i, - jod_total, - f"{i / fps:.6f}", - ] - - if args.pu_psnr_rgb2020: - row.append("" if pu is None else pu) - - row += [ - args.display, - "" if args.pix_per_deg is None else str(args.pix_per_deg), - args.temp_window, - args.device, - fps_ffmpeg, - f"{fps:.12f}", - ref_meta.get("codec_name", ""), - ref_meta.get("pix_fmt", ""), - ref_meta.get("color_transfer", ""), - ref_meta.get("color_primaries", ""), - ref_meta.get("color_space", ""), - ref_meta.get("color_range", ""), - ref_meta.get("width", ""), - ref_meta.get("height", ""), - test_meta.get("codec_name", ""), - test_meta.get("pix_fmt", ""), - test_meta.get("color_transfer", ""), - test_meta.get("color_primaries", ""), - test_meta.get("color_space", ""), - test_meta.get("color_range", ""), - test_meta.get("width", ""), - test_meta.get("height", ""), - ] - - w.writerow(row) - - if (i + 1) % 10 == 0 or i == n_frames - 1: - f.flush() - print(f" heatmaps+metrics: {i+1}/{n_frames}") - - heat_mov = outdir / f"heatmap_{args.mode}.mov" - png_pattern = str(heat_dir / "heatmap_%06d.png") - print(f"\nEncoding heatmap MOV from PNGs (no blending): {heat_mov}") - - encode_png_sequence_to_mov( - png_pattern, - fps_ffmpeg, - heat_mov, - source_is_pq=is_pq_video(test_meta), - verbose=args.verbose - ) - - compare_mov = None - if not args.no_compare: - compare_mov = outdir / f"compare_test_plus_heatmap_{args.mode}.mov" - if is_pq_video(test_meta): - print("Compare MOV mode: source is PQ, auto-upmapping heatmap to PQ/BT.2020 for side-by-side") - else: - print("Compare MOV mode: source is not PQ, using standard side-by-side pipeline") - print(f"Encoding side-by-side MOV: {compare_mov}") - encode_compare_mov( - test, - str(heat_mov), - compare_mov, - fps_ffmpeg, - test_meta=test_meta, - verbose=args.verbose - ) - - cleanup_heatmaps_if_success(heat_dir, heat_mov) - - print("\nDone.") - print(f" CSV: {out_csv}") - print(f" Heatmap MOV: {heat_mov}") - if compare_mov: - print(f" Compare MOV: {compare_mov}") - if args.keep_work: - print(f" Kept workdir: {work_dir}") - - finally: - if proc is not None: - stop_cvvdp_interactive(proc) - if temp_ctx is not None: - temp_ctx.cleanup() - - -if __name__ == "__main__": - main() \ No newline at end of file From 95baeca819dfd0fa68063415e3b7afcd67ead3b3 Mon Sep 17 00:00:00 2001 From: digitaltvguy Date: Fri, 6 Mar 2026 20:37:34 -0500 Subject: [PATCH 5/7] Create symlink-anaconda3-ffmpeg-to-homebrew.sh Creates symlinks in macOS homebrew anaconda3 environment for cvcdp ffmpeg to normal /opt/homebrew/bin/ffmpeg path so that more capable ffmpeg can be used. --- .../symlink-anaconda3-ffmpeg-to-homebrew.sh | 95 +++++++++++++++++++ 1 file changed, 95 insertions(+) create mode 100755 scripting/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh diff --git a/scripting/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh b/scripting/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh new file mode 100755 index 0000000..3a37a8e --- /dev/null +++ b/scripting/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh @@ -0,0 +1,95 @@ +#!/usr/bin/env bash + +set -e + +echo "----------------------------------------" +echo "Configure cvvdp env to use Homebrew FFmpeg" +echo "----------------------------------------" + +echo +echo "Detecting Homebrew..." + +BREW=$(command -v brew) + +if [ -z "$BREW" ]; then + echo "ERROR: Homebrew not found." + exit 1 +fi + +BREW_PREFIX=$($BREW --prefix) + +FFMPEG_SRC="$BREW_PREFIX/bin/ffmpeg" +FFPROBE_SRC="$BREW_PREFIX/bin/ffprobe" + +echo "Homebrew prefix: $BREW_PREFIX" + +if [ ! -f "$FFMPEG_SRC" ]; then + echo "ERROR: ffmpeg not found in Homebrew:" + echo "$FFMPEG_SRC" + exit 1 +fi + +if [ ! -f "$FFPROBE_SRC" ]; then + echo "ERROR: ffprobe not found in Homebrew." + exit 1 +fi + +echo +echo "Locating cvvdp conda environment..." + +ENV_BIN="" + +for root in \ +"$BREW_PREFIX/anaconda3/envs" \ +"$BREW_PREFIX/Caskroom/miniconda/base/envs" \ +"$HOME/miniconda3/envs" \ +"$HOME/anaconda3/envs" +do + if [ -d "$root/cvvdp/bin" ]; then + ENV_BIN="$root/cvvdp/bin" + break + fi +done + +if [ -z "$ENV_BIN" ]; then + echo "ERROR: Could not find cvvdp environment." + exit 1 +fi + +echo "cvvdp env bin: $ENV_BIN" + +FFMPEG_DST="$ENV_BIN/ffmpeg" +FFPROBE_DST="$ENV_BIN/ffprobe" + +echo +echo "Removing existing binaries..." + +[ -e "$FFMPEG_DST" ] && rm "$FFMPEG_DST" +[ -e "$FFPROBE_DST" ] && rm "$FFPROBE_DST" + +echo +echo "Creating symlinks..." + +ln -s "$FFMPEG_SRC" "$FFMPEG_DST" +ln -s "$FFPROBE_SRC" "$FFPROBE_DST" + +echo +echo "Symlinks created:" +ls -l "$FFMPEG_DST" +ls -l "$FFPROBE_DST" + +echo +echo "Checking zscale support..." + +if "$FFMPEG_DST" -filters | grep -q zscale; then + echo "✔ zscale detected" +else + echo "⚠ WARNING: zscale NOT detected" +fi + +echo +echo "Active ffmpeg version used by cvvdp:" +"$FFMPEG_DST" -version | head -n 3 + +echo +echo "Setup complete." \ No newline at end of file From c55437ea9bc68654fc5897cc74b8705b7dca9c07 Mon Sep 17 00:00:00 2001 From: digitaltvguy Date: Sat, 7 Mar 2026 12:29:42 -0500 Subject: [PATCH 6/7] Updated script with subsampling(upsampling) optimizations 1. filter=spline36 2. Updated install instructions 3. Moved macOS brew formula into folder --- build/homebrew/ffmpeg-custom.rb | 124 ------ scripting_build_macOS/macOS/Install cvvdp.pdf | Bin 0 -> 56406 bytes ...er_frame_csv_and_heatmap_subsampOPT-v33.py | 358 +++++++++++------- .../symlink-anaconda3-ffmpeg-to-homebrew.sh | 0 4 files changed, 213 insertions(+), 269 deletions(-) delete mode 100644 build/homebrew/ffmpeg-custom.rb create mode 100644 scripting_build_macOS/macOS/Install cvvdp.pdf rename scripting/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py => scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap_subsampOPT-v33.py (82%) rename {scripting => scripting_build_macOS}/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh (100%) diff --git a/build/homebrew/ffmpeg-custom.rb b/build/homebrew/ffmpeg-custom.rb deleted file mode 100644 index e43f8f4..0000000 --- a/build/homebrew/ffmpeg-custom.rb +++ /dev/null @@ -1,124 +0,0 @@ -class FfmpegCustom < Formula - desc "FFmpeg with stock Homebrew features plus additional codecs and libraries" - homepage "https://ffmpeg.org/" - url "https://ffmpeg.org/releases/ffmpeg-8.0.1.tar.xz" - sha256 "05ee0b03119b45c0bdb4df654b96802e909e0a752f72e4fe3794f487229e5a41" - revision 2 - license "GPL-2.0-or-later" - - depends_on "pkgconf" => :build - - # Stock Homebrew ffmpeg dependencies - depends_on "dav1d" - depends_on "lame" - depends_on "libvpx" - depends_on "openssl@3" - depends_on "opus" - depends_on "sdl2" - depends_on "svt-av1" - depends_on "x264" - depends_on "x265" - - # Additional custom dependencies - depends_on "aom" - depends_on "fdk-aac" - depends_on "freetype" - depends_on "harfbuzz" - depends_on "libass" - depends_on "librist" - depends_on "libsoxr" - depends_on "libvmaf" - depends_on "openjpeg" - depends_on "srt" - depends_on "two-lame" - depends_on "zimg" - - uses_from_macos "bzip2" - uses_from_macos "libxml2" - uses_from_macos "zlib" - - on_intel do - depends_on "nasm" => :build - end - - on_linux do - depends_on "alsa-lib" - depends_on "libxcb" - depends_on "xz" - depends_on "zlib-ng-compat" - end - - patch do - url "https://gitlab.archlinux.org/archlinux/packaging/packages/ffmpeg/-/raw/5670ccd86d3b816f49ebc18cab878125eca2f81f/add-av_stream_get_first_dts-for-chromium.patch" - sha256 "57e26caced5a1382cb639235f9555fc50e45e7bf8333f7c9ae3d49b3241d3f77" - end - - patch do - url "https://git.ffmpeg.org/gitweb/ffmpeg.git/patch/a5d4c398b411a00ac09d8fe3b66117222323844c" - sha256 "1dbbc1a4cf9834b3902236abc27fefe982da03a14bcaa89fb90c7c8bd10a1664" - end - - def install - ENV.append "LDFLAGS", "-Wl,-ld_classic" if OS.mac? && DevelopmentTools.ld64_version.between?("1015.7", "1022.1") - - args = %W[ - --prefix=#{prefix} - --enable-shared - --enable-pthreads - --enable-version3 - --enable-gpl - --enable-nonfree - - --cc=#{ENV.cc} - --host-cflags=#{ENV.cflags} - --host-ldflags=#{ENV.ldflags} - - --enable-ffplay - --enable-libsvtav1 - --enable-libopus - --enable-libx264 - --enable-libmp3lame - --enable-libdav1d - --enable-libvpx - --enable-libx265 - --enable-openssl - - --enable-libaom - --enable-libfdk-aac - --enable-libtwolame - --enable-librist - --enable-libsrt - --enable-libass - --enable-libfreetype - --enable-libharfbuzz - --enable-libsoxr - --enable-libzimg - --enable-libvmaf - --enable-libopenjpeg - ] - - if system("pkg-config", "--exists", "fribidi") - args << "--enable-libfribidi" - end - - if OS.mac? - args << "--enable-videotoolbox" - args << "--enable-audiotoolbox" - end - - args << "--enable-neon" if Hardware::CPU.arm? - - system "./configure", *args - system "make" - system "make", "install" - system "make", "alltools" - - bin.install Dir["tools/*"].select { |f| File.file?(f) && File.executable?(f) } - pkgshare.install "tools/python" if Dir.exist?("tools/python") - end - - test do - output = shell_output("#{bin}/ffmpeg -version") - assert_match "ffmpeg version", output - end -end diff --git a/scripting_build_macOS/macOS/Install cvvdp.pdf b/scripting_build_macOS/macOS/Install cvvdp.pdf new file mode 100644 index 0000000000000000000000000000000000000000..e27bb7734ef1d3040ed39359dbb7e935c1d557f7 GIT binary patch literal 56406 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%0A4R=^)q+R>cFljK- z!W0r91};E@Q_kqmOfP(`%$RZAU#8`Gz?P0bw?zBM$REP9dye;hii^8?KWagV|pS!m-(G3vWW6a zGV8TKl%H(RuDIJh?d}nhq;8s~ZuR}voFD{ac@mfjG)5KH;hU!ixt`^<2blv8dSX6C{xGRjivy{me(XQ#u z&imF$nuobcW}o0Tw$l$PJy~C@D7szi46E6DF7YUv=e$x>8tQs?%%X&e)-zN_i%~P} z1^;Y~QAt$JF*ZvZ#rxM%;Cl{s~rvzCo=`dfP+B_oiM^Y>M}K zvL_t9G|>p!ul65rIq{dPe2wFaFfb!&C#B>TKikdY=JG8oF3fKm+n=Y(Y`l){ej|$P z4gTTa_^6atEPFC}Ih2aYRWpqJI7qD>YrsB)NlaHFX;J;6>pckQ9}`~LTRX7}(c z7uR#G?&!99A9X;F9+2E)(5XHBY(sb6=tDJw*OhdwUG)>Ai6;@2Degk^qz~Di9qPR7 z-D8(7oPRA~22y&{E4d%Cl#Ct@dSY6b8a3>wa+dxGE3w#Vh$Y?a`c0pCobi!GzZ5oq ziy==glkFf|N-Q<^HX0=}@d!nbXqEQwXBIfLwxFkC(6pme z_1Uz(g6HK=zWFlZ_m1~pw2GLD8%DhlUe1n6;53XImJup!S!)$v<7VPX&MC>WXH?-y}`C)3@yj4S6*osF=bZ z4gE_rMXB7&t?Y9ze%z~Gz=To_je0*}__++tfD+@|1D97T zv;FygoNYhj!Dk}Zvz!rAPew_;6S(8p~(T_cslV2Up5J|I6q0Hw~ajgS+X?H1HQC;T`4s#fLx>001n6 zhQos#bDL=>6q1tnm-788i$)R&;4R`N8U`fRg_iw`1Hy;-33I%u?3R2)BDl`pToxcv z19auhGz10#<3k|{AUEq~J`_MeuXhX+~sAT)3V z4^rS!zTfh|yFQp+h$!&N0Lq60sRcLFP+;7kH1M8|0-a0w{#6Ga1(Wxa1H!ij4GE?O zv<@VKg899f7 z4-6%&EjR$Z4dcTD5L;+j0s>x^KuJ^xse?#KFu9qAhp!_5aZ7<(hVUVQ(+#DefVU5& zpMLzRnSNcv}zz*uF)1vd2U8N2|8^$p~Fh06oKRe{w(0B`1?^%CH^Lc{lIBpRTg zL(8I3c$hpi8ooZzSP5!EHG?P8rVKyI>1oUD?`cxQv#bC7`X3)#3JFo6c%(CDi4bS4=Eva zV7J)wu#|MlP(Cbttzz-;{RoSP@4r|g7PdZcNFq!xxGm-o$S@5ri-PMOhlY35MVSS%+_HrIM`TX zfLjdh8wNulLf0D>frk0EAb~k-EU^gOzxOvEFS64~J1=_5&)5u{0_`XVQ9jvY?(RN( zNG#9+<#%XD-0a=KU!caHf2s1B^U2$i33lM5g+SZe+S!1kqb-7X!q(oFOa{jXBEil^ gk^cW@`Rk^_+lTDs^YavpMFCfrURYR7?=b!U06v1_hX4Qo literal 0 HcmV?d00001 diff --git a/scripting/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py b/scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap_subsampOPT-v33.py similarity index 82% rename from scripting/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py rename to scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap_subsampOPT-v33.py index 97df151..44e1185 100755 --- a/scripting/macOS/cvvdp_per_frame_csv_and_heatmap_v32.3.py +++ b/scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap_subsampOPT-v33.py @@ -1,19 +1,20 @@ #!/opt/homebrew/anaconda3/envs/cvvdp/bin/python """ -cvvdp_per_frame_csv_and_heatmap_v32_2.py +cvvdp_per_frame_csv_and_heatmap_v33.py Features -------- • Drag-and-drop friendly (interactive prompts if paths not supplied) • Path cleanup for macOS Terminal drag-drop (quotes, escaped spaces, CR) • Extract REF+TEST to 16-bit RGB TIFF sequences ONCE (frame index source of truth) -• Optional full-screen resize is applied during TIFF extraction (ffmpeg scale to --display-res) - because ColorVideoVDP does NOT implement --full-screen-resize for IMAGE SEQUENCES. +• REF is extracted in TWO ways: + 1) native full-resolution RGB reference + 2) simulated-4:2:0 reference (444/422 -> 420 -> 444) for encoder-only fairness analysis +• TIFF extraction uses zscale by default with filter=spline36 • Per-frame CVVDP JOD + per-frame heatmap PNG (one PNG per source frame) - - Optional temporal window: run cvvdp on a multi-frame window (i-k):(i+k), but: - - Metric reported is for the window; we log it at the center frame index i - - Heatmap output may contain multiple frames; we extract ONLY the CENTER heatmap frame -• Optional per-frame PU-PSNR-RGB2020 (HDR-aware PSNR variant provided by cvvdp) + - Heatmaps are generated from the NATIVE reference analysis +• Optional temporal window: run cvvdp on a multi-frame window (i-k):(i+k) +• Optional per-frame PU-PSNR-RGB2020 for BOTH analyses • Optional cvvdp debug dumps via: --dump-channels temporal|lpyr|difference [...] - Can optionally preserve those outputs with: @@ -28,15 +29,16 @@ • Automatic PNG cleanup after successful heatmap MOV creation (keeps PNGs if MOV fails) • CSV includes legends + options used (# key=value lines) -Notes on temporal artifacts ---------------------------- -- If --temp-window=0 (default): CVVDP runs ONLY on frame i (no temporal measurement). -- If --temp-window>0: CVVDP runs on frames (i-k):(i+k). This allows the metric to - incorporate temporal mechanisms, BUT you must interpret the reported JOD as the - quality of that WINDOW. We log it against the center frame i. -- Heatmaps: when windowing is enabled, CVVDP may output multiple heatmap frames. - This script extracts the CENTER heatmap frame (i) so you still get exactly ONE PNG - per output frame. +Two analysis modes written to CSV +--------------------------------- +jod_total + Native reference vs test. + This measures real delivered quality (444 reference vs encoded test). + +jod_total_ref420sim + Simulated-420 reference vs test. + This reduces bias from chroma subsampling by putting the reference through + 444/422 -> 420 -> 444 reconstruction before comparison. Modes ----- @@ -46,73 +48,12 @@ raw : raw perceptual error energy map (implementation-dependent) USAGE: - python cvvdp_per_frame_csv_and_heatmap_v32_2.py REF.mov TEST.mov OUTDIR [options] - (or run without args and it will prompt you to drag/drop paths) + python cvvdp_per_frame_csv_and_heatmap_v33.py REF.mov TEST.mov OUTDIR [options] Example: - python cvvdp_per_frame_csv_and_heatmap_v32_2.py ref.mov test.mov out \ + python cvvdp_per_frame_csv_and_heatmap_v33.py ref.mov test.mov out \ --display NBCU_65inch_hdr_pq_2knit --device mps --mode supra-threshold \ --temp-window 0 --pu-psnr-rgb2020 - -IMPORTANT: -- pix/deg override is OPTIONAL. If not provided, the DISPLAY PROFILE governs pix/deg. - Use --pix-per-deg ONLY if you intentionally want to override the display model. - -ΔE-ITP ------- -Removed (prior implementation was not validated for HDR PQ/ICtCp correctness). - ---dump-channels temporal ------------------------- -Use when you’re trying to answer: “Is the metric reacting to time the way I think it is?” - -Best use-cases: - • Validate temporal-window choices (--temp-window, and in general whether motion/temporal masking is kicking in). - • Diagnose ‘temporal weirdness’: quality looks worse/better than expected during fast motion, cuts, flashes, flicker, or scrolling text. - • Debug framerate / cadence issues (e.g., accidental 23.976 vs 24 vs 59.94 conversions, duplicated frames, bad decimation). - • Sanity-check temporal padding (--temp-padding replicate|pingpong|circular) near start/end of clips when windowing is used. - -What you typically look for: - • “Temporal” intermediate outputs should show motion-related processing behaving sensibly - (not “stuck,” not exploding at cuts, not showing obvious cadence artifacts). - ---dump-channels lpyr --------------------- -Use when you’re trying to answer: “Which spatial frequencies are triggering the score/heatmap?” - -Best use-cases: - • Find whether the ‘damage’ is high-frequency (ringing, sharpening halos, mosquito noise) vs mid/low (blur, banding, blocking). - • Compare two encodes where the JOD difference is small but you want to know why. - • Debug resize/sharpen pipelines (especially if you are scaling in ffmpeg before cvvdp by extracting TIFFs at a display-res). - -What you typically look for: - • If artifacts are mostly in high bands → expect ringing/noise; mid bands → texture loss; - low bands → luminance/contrast structure shifts. - ---dump-channels difference --------------------------- -Use when you’re trying to answer: “What exact error field is cvvdp feeding into the final perceptual model?” - -Best use-cases: - • Explain surprising heatmaps: you see red blobs in weird places and want to know if it’s - from color conversion, luma differences, or content structure. - • Spot alignment problems (1px shifts, resample mismatch, scaling mismatch, chroma siting issues) - because the “difference” stage will often make these scream. - • Verify you’re not measuring decode/convert mistakes (wrong transfer interpretation, - wrong matrix/range assumptions) rather than actual codec differences. - -What you typically look for: - • A clean “difference” field that corresponds to real visible changes, not global offsets/tints - that imply upstream pipeline issues. - -How this ties back to optimized workflows ------------------------------------------ - • If you’re iterating on settings (temp window, scaling, framerate, padding) and need fast confidence, - --dump-channels temporal is the highest value. - • If you’re tuning encode settings (bitrate, psychovisual knobs, sharpening, grain), - --dump-channels lpyr is the best “why” tool. - • If you suspect you’re measuring the wrong thing (colorspace/TF/range mismatch, alignment), - --dump-channels difference saves the most time. """ import argparse @@ -309,7 +250,7 @@ def ffmpeg_has_zscale() -> bool: # ------------------------------------------------------------- -# Display-res parsing / scaling flags +# Display-res parsing # ------------------------------------------------------------- def parse_display_res(res: str) -> Tuple[int, int]: @@ -320,33 +261,74 @@ def parse_display_res(res: str) -> Tuple[int, int]: return int(w), int(h) -def ffmpeg_scale_flags(full_screen_resize: str) -> str: - m = { - "bilinear": "bilinear", - "bicubic": "bicubic", - "nearest": "neighbor", - "area": "area", - } - return m[full_screen_resize] - - # ------------------------------------------------------------- # Frame extraction (TIFF) # ------------------------------------------------------------- -def extract_tiffs(video: str, - out_dir: Path, - prefix: str, - full_screen_resize: str, - display_res: Tuple[int, int], - verbose: bool = False) -> Path: +def extract_tiffs_native(video: str, + out_dir: Path, + prefix: str, + display_res: Tuple[int, int], + chroma_filter: str, + verbose: bool = False) -> Path: + """ + Extract frames as RGB48 TIFF using zscale for resize, chroma reconstruction, + and color conversion. + """ + ffmpeg = which_or_raise("ffmpeg") + out_dir.mkdir(parents=True, exist_ok=True) + pattern = out_dir / f"{prefix}_%06d.tif" + + w, h = display_res + vf = ( + f"zscale=" + f"w={w}:h={h}:" + f"filter={chroma_filter}:" + f"dither=error_diffusion," + f"format=rgb48le" + ) + + run([ + ffmpeg, "-hide_banner", "-y", + "-i", video, + "-vsync", "0", + "-start_number", "0", + "-vf", vf, + str(pattern) + ], verbose=verbose) + + return pattern + + +def extract_tiffs_ref420sim(video: str, + out_dir: Path, + prefix: str, + display_res: Tuple[int, int], + chroma_filter: str, + verbose: bool = False) -> Path: + """ + Extract reference frames after simulating 4:2:0: + source -> scale/convert -> yuv420p10le -> upsample -> rgb48le TIFF + + This is useful for encoder-only fairness analysis when the true reference is 444/422. + """ ffmpeg = which_or_raise("ffmpeg") out_dir.mkdir(parents=True, exist_ok=True) pattern = out_dir / f"{prefix}_%06d.tif" w, h = display_res - flags = ffmpeg_scale_flags(full_screen_resize) - vf = f"scale={w}:{h}:flags={flags}" + vf = ( + f"zscale=" + f"w={w}:h={h}:" + f"filter={chroma_filter}:" + f"dither=error_diffusion," + f"format=yuv420p10le," + f"zscale=" + f"w={w}:h={h}:" + f"filter={chroma_filter}:" + f"dither=error_diffusion," + f"format=rgb48le" + ) run([ ffmpeg, "-hide_banner", "-y", @@ -354,7 +336,6 @@ def extract_tiffs(video: str, "-vsync", "0", "-start_number", "0", "-vf", vf, - "-pix_fmt", "rgb48le", str(pattern) ], verbose=verbose) @@ -625,6 +606,54 @@ def run_cvvdp_window_and_heatmap( temp_ctx.cleanup() +def run_cvvdp_jod_only( + cvvdp_exe: str, + ref_pat: Path, + test_pat: Path, + center_frame: int, + temp_window: int, + display: str, + device: str, + fps: float, + pix_per_deg: Optional[float], + temp_resample: bool, + temp_padding: str, + verbose: bool = False +) -> float: + k = max(0, int(temp_window)) + start = max(0, int(center_frame) - k) + end = int(center_frame) + k + + cmd = [ + str(cvvdp_exe), + "-q", + "--ffmpeg-cc", + "--device", str(device), + "--display", str(display), + "--fps", f"{float(fps):.12f}", + "--frames", f"{start}:{end}", + "--ref", str(ref_pat), + "--test", str(test_pat), + ] + + if temp_resample: + cmd.insert(2, "--temp-resample") + + if temp_padding: + cmd.insert(2, temp_padding) + cmd.insert(2, "--temp-padding") + + if pix_per_deg is not None: + idx = cmd.index("--fps") + cmd[idx:idx] = ["--pix-per-deg", str(pix_per_deg)] + + out = run(cmd, verbose=verbose) + v = parse_metric_value(out) + if v is None: + raise RuntimeError(f"Could not parse JOD for frame={center_frame}\n--- output ---\n{out}") + return float(v) + + # ------------------------------------------------------------- # Run CVVDP: PU-PSNR-RGB2020 per frame (optional) # ------------------------------------------------------------- @@ -681,22 +710,10 @@ def encode_png_sequence_to_mov( source_is_pq: bool, verbose: bool = False ) -> None: - """ - Stitch PNGs -> CFR ProRes MOV. - - If source_is_pq=True: - Treat heatmap PNGs as full-range RGB graphics (sRGB-ish / BT.709 primaries), - convert to linear display light, - apply 2x light scaling, - convert to PQ BT.2020, - then convert to BT.2020nc YUV for ProRes output, - and tag output as PQ/BT.2020. - """ ffmpeg = which_or_raise("ffmpeg") if source_is_pq: vf = ( - # Decode PNG RGB as RGB full-range, assume sRGB transfer + BT.709 primaries "zscale=" "matrixin=gbr:" "transferin=bt709:" @@ -706,12 +723,10 @@ def encode_png_sequence_to_mov( "transfer=linear:" "primaries=bt2020:" "range=full," - # 2x scale in linear display light "lutrgb=" "r='clip(val*2,0,maxval)':" "g='clip(val*2,0,maxval)':" "b='clip(val*2,0,maxval)'," - # Linear BT.2020 RGB -> PQ BT.2020 RGB "zscale=" "matrixin=gbr:" "transferin=linear:" @@ -721,7 +736,6 @@ def encode_png_sequence_to_mov( "transfer=smpte2084:" "primaries=bt2020:" "range=full," - # PQ BT.2020 RGB -> PQ BT.2020 YUV "zscale=" "matrixin=gbr:" "transferin=smpte2084:" @@ -765,7 +779,6 @@ def encode_png_sequence_to_mov( ], verbose=verbose) - def encode_compare_mov( test: str, heat_mov: str, @@ -774,11 +787,6 @@ def encode_compare_mov( test_meta: Dict[str, str], verbose: bool = False ) -> None: - """ - Side-by-side compare MOV (TEST | HEATMAP MOV), CFR, no blending. - - If TEST is PQ, assume HEATMAP MOV is already PQ/BT.2020. - """ ffmpeg = which_or_raise("ffmpeg") _, heat_h = ffprobe_wh(heat_mov) @@ -844,6 +852,7 @@ def encode_compare_mov( str(out_compare_mov) ], verbose=verbose) + def cleanup_heatmaps_if_success(heat_dir: Path, heat_mov: Path) -> None: try: if heat_mov.exists() and heat_mov.stat().st_size > 50_000: @@ -870,7 +879,7 @@ def args_to_kv_lines(args: argparse.Namespace, extra: Dict[str, str]) -> List[st preferred = [ "display", "pix_per_deg", "mode", "temp_window", "device", "pu_psnr_rgb2020", - "full_screen_resize", "display_res", + "display_res", "chroma_filter", "temp_resample", "temp_padding", "dump_channels", "dump_output_dir", "no_compare", "keep_work", "limit_frames", "verbose", @@ -896,6 +905,9 @@ def write_csv_header_block(w: csv.writer, heatmap_mode: str, options_lines: List w.writerow(["# 3–5 : strong impairment"]) w.writerow(["# <3 : severe distortion"]) w.writerow([]) + w.writerow(["# jod_total = native reference vs test"]) + w.writerow(["# jod_total_ref420sim = simulated-420 reference vs test"]) + w.writerow([]) w.writerow([f"# Heatmap mode: {heatmap_mode}"]) if heatmap_mode == "supra-threshold": @@ -914,6 +926,7 @@ def write_csv_header_block(w: csv.writer, heatmap_mode: str, options_lines: List w.writerow(["# Dark : low error energy"]) w.writerow(["# Bright : high error energy"]) w.writerow(["# Note: heatmap visualizes spatial perceptual error under the selected display model."]) + w.writerow(["# Heatmaps/MOV are generated from the native-reference analysis only."]) w.writerow([]) w.writerow(["# Options used (including defaults):"]) @@ -927,7 +940,7 @@ def write_csv_header_block(w: csv.writer, heatmap_mode: str, options_lines: List # ------------------------------------------------------------- def main() -> None: - ap = argparse.ArgumentParser(description="CVVDP per-frame JOD + heatmap PNG seq + MOV.") + ap = argparse.ArgumentParser(description="CVVDP per-frame dual-analysis JOD + heatmap PNG seq + MOV.") ap.add_argument("ref", nargs="?", help="Reference video file") ap.add_argument("test", nargs="?", help="Test video file") ap.add_argument("outdir", nargs="?", help="Output directory") @@ -943,13 +956,13 @@ def main() -> None: ap.add_argument("--device", default="mps") ap.add_argument("--pu-psnr-rgb2020", dest="pu_psnr_rgb2020", action="store_true", - help="Also compute per-frame pu-psnr-rgb2020 via cvvdp (--metric pu-psnr-rgb2020).") + help="Also compute per-frame pu-psnr-rgb2020 via cvvdp for BOTH analyses.") - ap.add_argument("--full-screen-resize", default="bicubic", - choices=["bilinear", "bicubic", "nearest", "area"], - help="Applied during TIFF extraction (scale to --display-res).") ap.add_argument("--display-res", default="3840x2160", - help="Target resolution for full-screen scaling during extraction.") + help="Target resolution for TIFF extraction.") + ap.add_argument("--chroma-filter", default="spline36", + choices=["point", "bilinear", "bicubic", "spline16", "spline36", "lanczos"], + help="zscale filter used for resize and chroma upsampling during TIFF extraction.") ap.add_argument("--no-compare", action="store_true") ap.add_argument("--keep-work", action="store_true") ap.add_argument("--limit-frames", type=int, default=0) @@ -994,9 +1007,9 @@ def main() -> None: ref_meta = ffprobe_stream_meta(ref) test_meta = ffprobe_stream_meta(test) - if is_pq_video(test_meta) and not ffmpeg_has_zscale(): + if (is_pq_video(test_meta) or is_pq_video(ref_meta)) and not ffmpeg_has_zscale(): raise RuntimeError( - "Source video is PQ, but this ffmpeg build does not include zscale.\n" + "PQ source/reference detected, but this ffmpeg build does not include zscale.\n" "Install/rebuild ffmpeg with libzimg support." ) @@ -1017,8 +1030,8 @@ def main() -> None: print(f"pix/deg override: {args.pix_per_deg}") print(f"temp-window K: {args.temp_window}") print(f"pu-psnr-rgb2020: {'ON' if args.pu_psnr_rgb2020 else 'OFF'}") - print(f"full-screen-resize: {args.full_screen_resize} (applied during extraction)") print(f"display-res: {args.display_res}") + print(f"chroma-filter: {args.chroma_filter}") print(f"temp-resample: {'ON' if args.temp_resample else 'OFF'}") print(f"temp-padding: {args.temp_padding}") print(f"dump-channels: {args.dump_channels if args.dump_channels else '(none)'}") @@ -1026,7 +1039,8 @@ def main() -> None: print("ffmpeg:", which_or_raise("ffmpeg")) print("ffprobe:", which_or_raise("ffprobe")) print("cvvdp :", cvvdp_exe) - print(f"PQ source detected: {is_pq_video(test_meta)}\n") + print(f"PQ source detected: {is_pq_video(test_meta)}") + print(f"PQ reference detected: {is_pq_video(ref_meta)}\n") if args.keep_work: work_dir = Path(tempfile.mkdtemp(prefix="cvvdp_work_")) @@ -1038,23 +1052,41 @@ def main() -> None: proc: Optional[subprocess.Popen] = None try: - ref_dir = work_dir / "ref" + ref_dir_native = work_dir / "ref_native" + ref_dir_420sim = work_dir / "ref_420sim" test_dir = work_dir / "test" print("Extracting TIFF sequences (once)…") - ref_pat = extract_tiffs(ref, ref_dir, "ref", args.full_screen_resize, display_res, verbose=args.verbose) - test_pat = extract_tiffs(test, test_dir, "test", args.full_screen_resize, display_res, verbose=args.verbose) + ref_pat_native = extract_tiffs_native( + ref, ref_dir_native, "refn", + display_res, + chroma_filter=args.chroma_filter, + verbose=args.verbose + ) + ref_pat_420sim = extract_tiffs_ref420sim( + ref, ref_dir_420sim, "refs", + display_res, + chroma_filter=args.chroma_filter, + verbose=args.verbose + ) + test_pat = extract_tiffs_native( + test, test_dir, "test", + display_res, + chroma_filter=args.chroma_filter, + verbose=args.verbose + ) - n_ref = count_tiffs(ref_dir, "ref") + n_ref_native = count_tiffs(ref_dir_native, "refn") + n_ref_420sim = count_tiffs(ref_dir_420sim, "refs") n_test = count_tiffs(test_dir, "test") - n_frames = min(n_ref, n_test) + n_frames = min(n_ref_native, n_ref_420sim, n_test) if n_frames <= 0: - raise RuntimeError("No TIFF frames extracted (ref or test).") + raise RuntimeError("No TIFF frames extracted.") if args.limit_frames and args.limit_frames > 0: n_frames = min(n_frames, args.limit_frames) - print(f"TIFF frames: ref={n_ref}, test={n_test}, using={n_frames}") + print(f"TIFF frames: ref_native={n_ref_native}, ref_420sim={n_ref_420sim}, test={n_test}, using={n_frames}") print(f"Work dir: {work_dir} {'(kept)' if args.keep_work else '(temp)'}\n") out_csv = outdir / "metrics_per_frame.csv" @@ -1070,6 +1102,7 @@ def main() -> None: "fps_cvvdp_float": f"{fps:.12f}", "tiff_scale_to": f"{display_res[0]}x{display_res[1]}", "cvvdp_mode": "interactive" if args.pu_psnr_rgb2020 else "noninteractive_for_metrics", + "dual_analysis": "native_and_ref420sim", } options_lines = args_to_kv_lines(args, extra_opts) @@ -1084,10 +1117,11 @@ def main() -> None: headers = [ "frame", "jod_total", + "jod_total_ref420sim", "time_sec", ] if args.pu_psnr_rgb2020: - headers += ["pu_psnr_rgb2020"] + headers += ["pu_psnr_rgb2020", "pu_psnr_rgb2020_ref420sim"] headers += [ "cvvdp_display_model", @@ -1109,9 +1143,10 @@ def main() -> None: for i in range(n_frames): out_png = heat_dir / f"heatmap_{i:06d}.png" + # Native analysis (also generates heatmap) jod_total = run_cvvdp_window_and_heatmap( cvvdp_exe=cvvdp_exe, - ref_pat=ref_pat, + ref_pat=ref_pat_native, test_pat=test_pat, center_frame=i, temp_window=args.temp_window, @@ -1128,11 +1163,40 @@ def main() -> None: verbose=args.verbose ) - pu = None + # Simulated-420 reference analysis (JOD only) + jod_total_ref420sim = run_cvvdp_jod_only( + cvvdp_exe=cvvdp_exe, + ref_pat=ref_pat_420sim, + test_pat=test_pat, + center_frame=i, + temp_window=args.temp_window, + display=args.display, + device=args.device, + fps=fps, + pix_per_deg=args.pix_per_deg, + temp_resample=args.temp_resample, + temp_padding=args.temp_padding, + verbose=args.verbose + ) + + pu_native = None + pu_ref420sim = None if args.pu_psnr_rgb2020: - pu = run_cvvdp_pu_psnr_rgb2020( + pu_native = run_cvvdp_pu_psnr_rgb2020( + proc=proc, + ref_pat=ref_pat_native, + test_pat=test_pat, + center_frame=i, + temp_window=args.temp_window, + display=args.display, + device=args.device, + fps=fps, + pix_per_deg=args.pix_per_deg, + verbose=args.verbose + ) + pu_ref420sim = run_cvvdp_pu_psnr_rgb2020( proc=proc, - ref_pat=ref_pat, + ref_pat=ref_pat_420sim, test_pat=test_pat, center_frame=i, temp_window=args.temp_window, @@ -1146,11 +1210,15 @@ def main() -> None: row = [ i, jod_total, + jod_total_ref420sim, f"{i / fps:.6f}", ] if args.pu_psnr_rgb2020: - row.append("" if pu is None else pu) + row += [ + "" if pu_native is None else pu_native, + "" if pu_ref420sim is None else pu_ref420sim, + ] row += [ args.display, @@ -1186,7 +1254,7 @@ def main() -> None: heat_mov = outdir / f"heatmap_{args.mode}.mov" png_pattern = str(heat_dir / "heatmap_%06d.png") print(f"\nEncoding heatmap MOV from PNGs (no blending): {heat_mov}") - + encode_png_sequence_to_mov( png_pattern, fps_ffmpeg, diff --git a/scripting/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh b/scripting_build_macOS/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh similarity index 100% rename from scripting/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh rename to scripting_build_macOS/macOS/symlink-anaconda3-ffmpeg-to-homebrew.sh From 35ec22b040466e869ca31b083ba02ca9f65e0552 Mon Sep 17 00:00:00 2001 From: digitaltvguy Date: Sat, 7 Mar 2026 14:45:58 -0500 Subject: [PATCH 7/7] updated script removing pu-psnr-2020 code It won't accept 4:2:2 subsampled content so more coding is required to normalize images --- ...=> cvvdp_per_frame_csv_and_heatmap-v35.py} | 256 +++--------------- 1 file changed, 37 insertions(+), 219 deletions(-) rename scripting_build_macOS/macOS/{cvvdp_per_frame_csv_and_heatmap_subsampOPT-v33.py => cvvdp_per_frame_csv_and_heatmap-v35.py} (85%) diff --git a/scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap_subsampOPT-v33.py b/scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap-v35.py similarity index 85% rename from scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap_subsampOPT-v33.py rename to scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap-v35.py index 44e1185..d77ce0d 100755 --- a/scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap_subsampOPT-v33.py +++ b/scripting_build_macOS/macOS/cvvdp_per_frame_csv_and_heatmap-v35.py @@ -1,7 +1,42 @@ #!/opt/homebrew/anaconda3/envs/cvvdp/bin/python """ -cvvdp_per_frame_csv_and_heatmap_v33.py +cvvdp_per_frame_csv_and_heatmap_v35.py +Usage + +usage: cvvdp_per_frame_csv_and_heatmap_subsampOPT-v35.py [-h] [--mode {supra-threshold,threshold,raw}] [--display DISPLAY] + [--pix-per-deg PIX_PER_DEG] [--temp-window TEMP_WINDOW] [--device DEVICE] + [--display-res DISPLAY_RES] + [--chroma-filter {point,bilinear,bicubic,spline16,spline36,lanczos}] [--no-compare] + [--keep-work] [--limit-frames LIMIT_FRAMES] [--verbose] [--temp-resample] + [--temp-padding {replicate,pingpong,circular}] + [--dump-channels {temporal,lpyr,difference} [{temporal,lpyr,difference} ...]] + [--dump-output-dir DUMP_OUTPUT_DIR] + [ref] [test] [outdir] + +Modes +----- +--mode supra-threshold | threshold | raw + supra-threshold : perceptual supra-threshold difference map (often most useful) + threshold : detection-threshold map + raw : raw perceptual error energy map (implementation-dependent) + +Example: + python cvvdp_per_frame_csv_and_heatmap_v35.py ref.mov test.mov out \ + --display NBCU_65inch_hdr_pq_2knit --device mps --mode supra-threshold \ + --temp-window 0 + +Two analysis modes written to CSV +--------------------------------- +jod_total + Native reference vs test. + This measures real delivered quality (444 reference vs encoded test). + +jod_total_ref420sim + Simulated-420 reference vs test. + This reduces bias from chroma subsampling by putting the reference through + 444/422 -> 420 -> 444 reconstruction before comparison. + Features -------- • Drag-and-drop friendly (interactive prompts if paths not supplied) @@ -14,12 +49,10 @@ • Per-frame CVVDP JOD + per-frame heatmap PNG (one PNG per source frame) - Heatmaps are generated from the NATIVE reference analysis • Optional temporal window: run cvvdp on a multi-frame window (i-k):(i+k) -• Optional per-frame PU-PSNR-RGB2020 for BOTH analyses • Optional cvvdp debug dumps via: --dump-channels temporal|lpyr|difference [...] - Can optionally preserve those outputs with: --dump-output-dir /path/to/folder -• Uses cvvdp INTERACTIVE MODE (-i) only when needed for optional PU-PSNR-RGB2020 • Heatmap PNG sequence → ProRes MOV (CFR, no frame blending) • Optional side-by-side compare MOV: (TEST | HEATMAP MOV) - Automatically scales TEST to match HEATMAP height so hstack never fails @@ -29,49 +62,19 @@ • Automatic PNG cleanup after successful heatmap MOV creation (keeps PNGs if MOV fails) • CSV includes legends + options used (# key=value lines) -Two analysis modes written to CSV ---------------------------------- -jod_total - Native reference vs test. - This measures real delivered quality (444 reference vs encoded test). - -jod_total_ref420sim - Simulated-420 reference vs test. - This reduces bias from chroma subsampling by putting the reference through - 444/422 -> 420 -> 444 reconstruction before comparison. - -Modes ------ ---mode supra-threshold | threshold | raw - supra-threshold : perceptual supra-threshold difference map (often most useful) - threshold : detection-threshold map - raw : raw perceptual error energy map (implementation-dependent) - -USAGE: - python cvvdp_per_frame_csv_and_heatmap_v33.py REF.mov TEST.mov OUTDIR [options] - -Example: - python cvvdp_per_frame_csv_and_heatmap_v33.py ref.mov test.mov out \ - --display NBCU_65inch_hdr_pq_2knit --device mps --mode supra-threshold \ - --temp-window 0 --pu-psnr-rgb2020 """ import argparse import csv import json -import re -import select -import shlex import shutil import subprocess import sys import tempfile -import time from pathlib import Path from typing import Optional, Dict, List, Tuple VIDEO_EXTS = {".mp4", ".mov", ".mkv", ".m4v"} -_FLOAT_LINE_RE = re.compile(r"^\s*[-+]?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\s*$") # ------------------------------------------------------------- @@ -385,92 +388,6 @@ def parse_metric_value(output: str) -> Optional[float]: return None -# ------------------------------------------------------------- -# Interactive cvvdp helpers -# ------------------------------------------------------------- - -def _join_tokens_for_interactive(tokens: List[str]) -> str: - return " ".join(shlex.quote(t) for t in tokens) - - -def start_cvvdp_interactive(cvvdp_exe: str, verbose: bool = False) -> subprocess.Popen: - cmd = [cvvdp_exe, "-i"] - if verbose: - print("Starting cvvdp interactive:", " ".join(cmd)) - p = subprocess.Popen( - cmd, - stdin=subprocess.PIPE, - stdout=subprocess.PIPE, - stderr=subprocess.STDOUT, - text=True, - bufsize=1, - ) - assert p.stdin and p.stdout - return p - - -def _read_one_result_block(proc: subprocess.Popen, timeout_sec: float = 180.0) -> str: - assert proc.stdout - t0 = time.time() - lines: List[str] = [] - - while True: - if time.time() - t0 > timeout_sec: - raise RuntimeError("cvvdp interactive read timed out") - - line = proc.stdout.readline() - if line == "": - raise RuntimeError("cvvdp interactive process ended unexpectedly") - - lines.append(line) - s = line.strip() - - if _FLOAT_LINE_RE.match(s) or (s.startswith("cvvdp") and "=" in s): - end_t = time.time() + 0.05 - while time.time() < end_t: - r, _, _ = select.select([proc.stdout], [], [], 0.0) - if not r: - break - more = proc.stdout.readline() - if more == "": - break - lines.append(more) - return "".join(lines) - - -def cvvdp_interactive_run(proc: subprocess.Popen, tokens: List[str], verbose: bool = False) -> str: - assert proc is not None - assert proc.stdin and proc.stdout - line = _join_tokens_for_interactive(tokens) + "\n" - if verbose: - print("cvvdp(i) <=", line.strip()) - proc.stdin.write(line) - proc.stdin.flush() - out = _read_one_result_block(proc) - if verbose: - print("cvvdp(i) =>", out.strip()) - return out - - -def stop_cvvdp_interactive(proc: subprocess.Popen) -> None: - try: - if proc.stdin: - proc.stdin.close() - except Exception: - pass - try: - proc.terminate() - except Exception: - pass - try: - proc.wait(timeout=2.0) - except Exception: - try: - proc.kill() - except Exception: - pass - - # ------------------------------------------------------------- # Heatmap extraction helpers # ------------------------------------------------------------- @@ -654,51 +571,6 @@ def run_cvvdp_jod_only( return float(v) -# ------------------------------------------------------------- -# Run CVVDP: PU-PSNR-RGB2020 per frame (optional) -# ------------------------------------------------------------- - -def run_cvvdp_pu_psnr_rgb2020( - proc: subprocess.Popen, - ref_pat: Path, - test_pat: Path, - center_frame: int, - temp_window: int, - display: str, - device: str, - fps: float, - pix_per_deg: Optional[float], - verbose: bool = False -) -> Optional[float]: - k = max(0, int(temp_window)) - start = max(0, int(center_frame) - k) - end = int(center_frame) + k - - cmd = [ - "-q", - "--ffmpeg-cc", - "--device", str(device), - "--display", str(display), - "--metric", "pu-psnr-rgb2020", - "--fps", f"{float(fps):.12f}", - "--frames", f"{start}:{end}", - "--ref", str(ref_pat), - "--test", str(test_pat), - ] - if pix_per_deg is not None: - idx = cmd.index("--fps") - cmd[idx:idx] = ["--pix-per-deg", str(pix_per_deg)] - - try: - out = cvvdp_interactive_run(proc, cmd, verbose=verbose) - v = parse_metric_value(out) - return None if v is None else float(v) - except Exception as e: - if verbose: - print(f"[warn] pu-psnr-rgb2020 failed on frame {center_frame}: {e}") - return None - - # ------------------------------------------------------------- # Heatmap MOV + Compare MOV # ------------------------------------------------------------- @@ -878,7 +750,6 @@ def args_to_kv_lines(args: argparse.Namespace, extra: Dict[str, str]) -> List[st preferred = [ "display", "pix_per_deg", "mode", "temp_window", "device", - "pu_psnr_rgb2020", "display_res", "chroma_filter", "temp_resample", "temp_padding", "dump_channels", "dump_output_dir", "no_compare", "keep_work", "limit_frames", @@ -955,9 +826,6 @@ def main() -> None: help="Temporal half-window k. Uses frames (i-k):(i+k). 0 = single-frame only.") ap.add_argument("--device", default="mps") - ap.add_argument("--pu-psnr-rgb2020", dest="pu_psnr_rgb2020", action="store_true", - help="Also compute per-frame pu-psnr-rgb2020 via cvvdp for BOTH analyses.") - ap.add_argument("--display-res", default="3840x2160", help="Target resolution for TIFF extraction.") ap.add_argument("--chroma-filter", default="spline36", @@ -1029,7 +897,6 @@ def main() -> None: else: print(f"pix/deg override: {args.pix_per_deg}") print(f"temp-window K: {args.temp_window}") - print(f"pu-psnr-rgb2020: {'ON' if args.pu_psnr_rgb2020 else 'OFF'}") print(f"display-res: {args.display_res}") print(f"chroma-filter: {args.chroma_filter}") print(f"temp-resample: {'ON' if args.temp_resample else 'OFF'}") @@ -1049,8 +916,6 @@ def main() -> None: temp_ctx = tempfile.TemporaryDirectory(prefix="cvvdp_work_") work_dir = Path(temp_ctx.name) - proc: Optional[subprocess.Popen] = None - try: ref_dir_native = work_dir / "ref_native" ref_dir_420sim = work_dir / "ref_420sim" @@ -1101,14 +966,11 @@ def main() -> None: "fps_ffmpeg": fps_ffmpeg, "fps_cvvdp_float": f"{fps:.12f}", "tiff_scale_to": f"{display_res[0]}x{display_res[1]}", - "cvvdp_mode": "interactive" if args.pu_psnr_rgb2020 else "noninteractive_for_metrics", + "cvvdp_mode": "noninteractive_for_metrics", "dual_analysis": "native_and_ref420sim", } options_lines = args_to_kv_lines(args, extra_opts) - if args.pu_psnr_rgb2020: - proc = start_cvvdp_interactive(cvvdp_exe, verbose=args.verbose) - print(f"Building per-frame CSV: {out_csv}") with out_csv.open("w", newline="") as f: w = csv.writer(f) @@ -1119,11 +981,6 @@ def main() -> None: "jod_total", "jod_total_ref420sim", "time_sec", - ] - if args.pu_psnr_rgb2020: - headers += ["pu_psnr_rgb2020", "pu_psnr_rgb2020_ref420sim"] - - headers += [ "cvvdp_display_model", "pix_per_deg_override", "temp_window_k", @@ -1179,48 +1036,11 @@ def main() -> None: verbose=args.verbose ) - pu_native = None - pu_ref420sim = None - if args.pu_psnr_rgb2020: - pu_native = run_cvvdp_pu_psnr_rgb2020( - proc=proc, - ref_pat=ref_pat_native, - test_pat=test_pat, - center_frame=i, - temp_window=args.temp_window, - display=args.display, - device=args.device, - fps=fps, - pix_per_deg=args.pix_per_deg, - verbose=args.verbose - ) - pu_ref420sim = run_cvvdp_pu_psnr_rgb2020( - proc=proc, - ref_pat=ref_pat_420sim, - test_pat=test_pat, - center_frame=i, - temp_window=args.temp_window, - display=args.display, - device=args.device, - fps=fps, - pix_per_deg=args.pix_per_deg, - verbose=args.verbose - ) - row = [ i, jod_total, jod_total_ref420sim, f"{i / fps:.6f}", - ] - - if args.pu_psnr_rgb2020: - row += [ - "" if pu_native is None else pu_native, - "" if pu_ref420sim is None else pu_ref420sim, - ] - - row += [ args.display, "" if args.pix_per_deg is None else str(args.pix_per_deg), args.temp_window, @@ -1291,8 +1111,6 @@ def main() -> None: print(f" Kept workdir: {work_dir}") finally: - if proc is not None: - stop_cvvdp_interactive(proc) if temp_ctx is not None: temp_ctx.cleanup()