diff --git a/README.md b/README.md index 8fb8365..637bf32 100644 --- a/README.md +++ b/README.md @@ -6,6 +6,7 @@ codecs using metrics such as: - Average SSIMULACRA2 across frames - Average Butteraugli (3pnorm) across frames +- Final CVVDP score across frames - Weighted XPSNR - VMAF NEG (Harmonic Mean) - VMAF @@ -53,16 +54,11 @@ PSY-EX Metrics enables you to: - [uv](https://github.com/astral-sh/uv/blob/main/README.md), a Python project manager -- FFmpeg >= 7.1 (required for XPSNR) -- VapourSynth, and required plugins: - - ffms2 - - [vszip](https://github.com/dnjulek/vapoursynth-zip) - - [vship](https://github.com/Line-fr/Vship) [Optional: provides GPU support] +- FFmpeg >= 7.1 (required for XPSNR and video processing) +- [FFVship](https://github.com/Line-fr/FFVship) (Standalone tool for GPU-accelerated metrics) ### Install Steps -0. Install required dependencies outlined in the previous section - 1. Clone the repository ```bash @@ -101,19 +97,19 @@ options: -h, --help show this help message and exit -e, --every EVERY Only score every nth frame. Default 1 (every frame) -g, --gpu-streams GPU_STREAMS - Number of GPU streams for SSIMULACRA2/Butteraugli + Number of GPU threads for FFVship (SSIMULACRA2/Butteraugli/CVVDP) -t, --threads THREADS - Number of threads for SSIMULACRA2/Butteraugli + Number of decoder threads for FFVship. Default 2 ``` Example: ```bash -./scores.py source.mkv distorted.mkv -e 3 +./scores.py source.mkv distorted.mkv -e 3 -g 8 -t 4 ``` This command compares a reference `source.mkv` with `distorted.mkv`, scoring -every 3rd frame. +every 3rd frame using 8 GPU threads and 4 decoder threads via FFVship. ### encode.py @@ -136,28 +132,12 @@ options: -b, --keep KEEP Output video file name -e, --every EVERY Only score every nth frame. Default 1 (every frame) -g, --gpu-streams GPU_STREAMS - Number of GPU streams for SSIMULACRA2/Butteraugli + Number of GPU threads for FFVship (SSIMULACRA2/Butteraugli/CVVDP) -t, --threads THREADS - Number of threads for SSIMULACRA2/Butteraugli + Number of decoder threads for FFVship. Default 2 -n, --no-metrics Skip metrics calculations ``` -Examples: - -```bash -./encode.py -i source.mkv --keep video.ivf -q 29 svtav1 -- --preset 2 -``` - -This command encodes `source.mkv` at a CRF of 29 using the SVT-AV1 encoder with -the `--preset 2` argument. It will print metrics after encoding. - -```bash -./encode.py -i source.mkv --keep video.ivf -q 29 -g 4 svtav1 -- --preset 8 -``` - -This command does the same as the previous command, but uses 4 GPU streams -(instead of using the CPU) as well as passing a higher preset value to SVT-AV1. - ### stats.py ```bash @@ -179,57 +159,12 @@ options: -o, --output OUTPUT Path to output CSV file -e, --every EVERY Only score every nth frame. Default 1 (every frame) -g, --gpu-streams GPU_STREAMS - Number of GPU streams for SSIMULACRA2/Butteraugli + Number of GPU threads for FFVship (SSIMULACRA2/Butteraugli/CVVDP) -t, --threads THREADS - Number of threads for SSIMULACRA2/Butteraugli + Number of decoder threads for FFVship. Default 2 -k, --keep Keep output video files ``` -Example: - -```bash -./stats.py \ - -i source.mkv \ - -q "20 25 30 35 40" \ - -o ./svtav1_2.3.0-B_p8.csv \ - -e 3 \ - svtav1 -- --preset 8 --tune 2 -``` - -This command processes `source.mkv` at quality levels 20, 25, 30, 35, & 40 using -the SVT-AV1 encoder, scoring every 3rd frame, and writes the results to an -output `svtav1_2.3.0-B_p8.csv`. - -You can also script this to run across multiple speed presets: - -```bash -#!/bin/bash -eu - -for speed in {2..6}; do - ./stats.py \ - -i ~/Videos/reference/*.y4m \ - -q "20 25 30 35 40" \ - -o ./svtav1_2.3.0-B_p${speed}.csv \ - -e 3 \ - svtav1 -- --preset $speed --tune 2 -done -``` - -This snippet here will run the same command as before, but across all speed -presets from 2 to 6 (naming the output CSV files accordingly). It will also -encode all of the files ending in `.y4m` in the `~/Videos/reference` directory. - -You can also use libvpx for VP9 benchmarking: - -```bash -./stats.py \ - -i source.mkv \ - -q "20 25 30 35 40" \ - -o ./libvpx_vp9.csv \ - -g 4 \ - vpxenc -- --cpu-used=4 -``` - ### plot.py ```bash @@ -245,39 +180,6 @@ options: -f, --format FORMAT Save the plot as 'svg', 'png', or 'webp' ``` -Example: - -```bash -./plot.py -i codec1_results.csv codec2_results.csv -f webp -``` - -This command reads `codec1_results.csv` and `codec2_results.csv`, generating -separate plots (one for each metric) as WebP images. - -It will also print BD-rate statistics for each metric, comparing the two results -from the CSV files. - -`plot.py` also outputs a CSV file containing the average encode time for an -input file accompanied by the corresponding average BD-rate. These statistics -can assist in looking at overall encoder efficiency across multiple speed -presets or configurations. - -### Run via Docker - - See the pre-requisites for host machine: -https://rocm.docs.amd.com/projects/install-on-linux/en/latest/how-to/docker.html - - -1. Build image against your GPU architechture: - ```bash - GPU_ARCH=$(amdgpu-arch) docker-compose --build - ``` - -2. Run `docker-compose`: - ```bash - docker-compose -f run -v :/videos metrics-rocm <..args> - ``` - ## License This project was originally authored by @gianni-rosato & is provided as FOSS diff --git a/scripts/encode.py b/scripts/encode.py index e9a916b..0f82cdb 100755 --- a/scripts/encode.py +++ b/scripts/encode.py @@ -5,8 +5,6 @@ # "argparse>=1.4.0", # "statistics>=1.0.3.5", # "tqdm>=4.67.1", -# "vapoursynth>=70", -# "vstools>=3.3.4", # ] # /// @@ -48,21 +46,21 @@ def main(): "encoder_args", nargs=argparse.REMAINDER, type=str, - help="Additional encoder arguments (pass these after a '--' delimiter)", + help="Additional_encoder arguments (pass these after a '--' delimiter)", ) parser.add_argument( "-g", "--gpu-streams", type=int, default=0, - help="Number of GPU streams for SSIMULACRA2/Butteraugli", + help="Number of FFVship GPU threads (SSIMULACRA2/Butteraugli/CVVDP)", ) parser.add_argument( "-t", "--threads", type=int, - default=0, - help="Number of threads for SSIMULACRA2/Butteraugli", + default=2, + help="Number of FFVship decoder threads. Default 2", ) parser.add_argument( "-n", "--no-metrics", action="store_true", help="Skip metrics calculations" @@ -92,8 +90,9 @@ def main(): v.calculate_ssimulacra2(s) v.print_ssimulacra2() v.calculate_butteraugli(s) - if gpu_streams: - v.print_butteraugli() + v.print_butteraugli() + v.calculate_cvvdp(s) + v.print_cvvdp() v.calculate_ffmpeg_metrics(s) v.print_ffmpeg_metrics() diff --git a/scripts/metrics.py b/scripts/metrics.py index 076eafc..ec52d59 100755 --- a/scripts/metrics.py +++ b/scripts/metrics.py @@ -1,16 +1,12 @@ import math import os import re +import shlex import statistics import subprocess import time from subprocess import Popen -import vapoursynth as vs -from tqdm import tqdm -from vapoursynth import VideoNode -from vstools import initialize_clip - class CoreVideo: """ @@ -27,9 +23,6 @@ class CoreVideo: video_width: int video_height: int - # VapourSynth video object - video: VideoNode - def __init__(self, pth: str, e: int, t: int, g: int) -> None: self.path = pth self.name = os.path.basename(pth) @@ -37,40 +30,38 @@ def __init__(self, pth: str, e: int, t: int, g: int) -> None: self.e = e self.threads = t self.gpu_streams = g - self.video = self.vapoursynth_init() self.video_width, self.video_height = self.get_video_dimensions() def get_input_filesize(self) -> int: """ - Get the input file size of the distorted video. + Get the filesize of the input video in bytes. """ return os.path.getsize(self.path) - def vapoursynth_init(self) -> VideoNode: - """ - Initialize VapourSynth video object for the distorted video. - """ - core = vs.core - print( - f"Using {self.threads} {'GPU' if self.gpu_streams else 'CPU'} threads for {'SSIMULACRA2 & Butteraugli' if self.gpu_streams else 'SSIMULACRA2'}" - ) - if self.gpu_streams: - print(f"Using {self.gpu_streams} GPU streams for SSIMULACRA2 & Butteraugli") - core.num_threads = self.threads - video = core.ffms2.Source(source=self.path, cache=False, threads=self.threads) - video = initialize_clip(video, bits=0) - video = video.resize.Bicubic(format=vs.RGBS) - if self.e > 1: - video = video.std.SelectEvery(cycle=self.e, offsets=0) - return video - def get_video_dimensions(self) -> tuple[int, int]: """ - Get the width & height of the distorted video. + Get the width & height of the video using ffprobe. """ - core = vs.core - src_data = core.ffms2.Source(source=self.path, cache=False, threads=int(-1)) - return (src_data.width, src_data.height) + cmd = [ + "ffprobe", + "-v", + "error", + "-select_streams", + "v:0", + "-show_entries", + "stream=width,height", + "-of", + "csv=s=x:p=0", + self.path, + ] + try: + result = subprocess.run(cmd, capture_output=True, text=True, check=True) + dimensions = result.stdout.strip().split("x") + if len(dimensions) == 2: + return int(dimensions[0]), int(dimensions[1]) + except Exception: + pass + return 0, 0 class DstVideo(CoreVideo): @@ -81,11 +72,13 @@ class DstVideo(CoreVideo): # SSIMULACRA2 scores ssimu2_avg: float ssimu2_sdv: float - ssimu2_p10: float + ssimu2_p05: float # Butteraugli scores - butter_dis: float - butter_mds: float + butter_3nm: float + + # CVVDP score + cvvdp: float # XPSNR scores xpsnr_y: float @@ -107,19 +100,12 @@ class DstVideo(CoreVideo): svt_triple: float def __init__(self, pth: str, e: int, t: int, g: int) -> None: - self.path = pth - self.name = os.path.basename(pth) - self.size = self.get_input_filesize() - self.e = e - self.video_width, self.video_height = self.get_video_dimensions() - self.threads = t - self.gpu_streams = g - self.video = self.vapoursynth_init() + super().__init__(pth, e, t, g) self.ssimu2_avg = 0.0 self.ssimu2_sdv = 0.0 - self.ssimu2_p10 = 0.0 - self.butter_dis = 0.0 - self.butter_mds = 0.0 + self.ssimu2_p05 = 0.0 + self.butter_3nm = 0.0 + self.cvvdp = 0.0 self.xpsnr_y = 0.0 self.xpsnr_u = 0.0 self.xpsnr_v = 0.0 @@ -130,79 +116,80 @@ def __init__(self, pth: str, e: int, t: int, g: int) -> None: self.psnr = 0.0 self.svt_triple = 0.0 - def calculate_ssimulacra2(self, src: CoreVideo) -> None: + def run_ffvship( + self, src: CoreVideo, metric: str, extra_args: list[str] | None = None + ) -> str: """ - Calculate SSIMULACRA2 score between a source video & a distorted video. + Run FFVship for a specific metric and return the full output string. """ + cmd = [ + "FFVship", + "-s", + src.path, + "-e", + self.path, + "-m", + metric, + "-g", + str(self.gpu_streams), + "-t", + str(self.threads), + "--every", + str(self.e), + ] + if extra_args: + cmd.extend(extra_args) - if self.gpu_streams: - ssimu2_obj = src.video.vship.SSIMULACRA2( - self.video, numStream=self.gpu_streams - ) - else: - ssimu2_obj = src.video.vszip.Metrics(self.video, [0]) - - ssimu2_list: list[float] = [] - with tqdm( - total=ssimu2_obj.num_frames, - desc="Calculating SSIMULACRA2 scores", - unit=" frame", - colour="blue", - ) as pbar: - for i, f in enumerate(ssimu2_obj.frames()): - ssimu2_list.append(f.props["_SSIMULACRA2"]) - pbar.update(1) - if not i % 24: - avg: float = sum(ssimu2_list) / len(ssimu2_list) - pbar.set_postfix( - { - "avg": f"{avg:.2f}", - } - ) - - self.ssimu2_avg, self.ssimu2_sdv, self.ssimu2_p10 = calc_some_scores( - ssimu2_list - ) + result = subprocess.run(cmd, capture_output=True, text=True, check=True) - def calculate_butteraugli( - self, - src: CoreVideo, - ) -> None: + return result.stdout + result.stderr + + def calculate_ssimulacra2(self, src: CoreVideo) -> None: + """ + Calculate SSIMULACRA2 score between a source video & a distorted video using FFVship. """ - Calculate Butteraugli score between a source video & a distorted video. + print("Calculating SSIMULACRA2... ", end="") + output = self.run_ffvship(src, "SSIMULACRA2") + + avg_match = re.search(r"Average\s*:\s*(\d+\.\d+)", output) + std_match = re.search(r"Standard Deviation\s*:\s*(\d+\.\d+)", output) + p5_match = re.search(r"5th percentile\s*:\s*(-?\d+\.\d+)", output) + + if avg_match: + self.ssimu2_avg = float(avg_match.group(1)) + print(f"({self.ssimu2_avg:.2f})") + if std_match: + self.ssimu2_sdv = float(std_match.group(1)) + if p5_match: + self.ssimu2_p05 = float(p5_match.group(1)) + + def calculate_butteraugli(self, src: CoreVideo) -> None: """ + Calculate Butteraugli score between a source video & a distorted video using FFVship. + """ + print("Calculating Butteraugli 3-norm... ", end="") + output = self.run_ffvship(src, "Butteraugli", ["--qnorm", "3"]) + + three_norm_match = re.search(r"-+3-Norm-+.*?(?:\n\n|$)", output, re.DOTALL) + if three_norm_match: + section = three_norm_match.group(0) + avg_match = re.search(r"Average\s*:\s*(\d+\.\d+)", section) + if avg_match: + self.butter_3nm = float(avg_match.group(1)) + print(f"({self.butter_3nm:.2f})") + + def calculate_cvvdp(self, src: CoreVideo) -> None: + """ + Calculate CVVDP score between a source video & a distorted video using FFVship. + """ + print("Calculating CVVDP... ", end="") + output = self.run_ffvship(src, "CVVDP") - if self.gpu_streams: - butter_obj = src.video.vship.BUTTERAUGLI( - self.video, numStream=self.gpu_streams - ) - else: - print("Skipping Butteraugli, no GPU threads available (set with -g)") - self.butter_dis = 0.0 - self.butter_mds = 0.0 - return - - butter_distance_list: list[float] = [] - with tqdm( - total=butter_obj.num_frames, - desc="Calculating Butteraugli scores", - unit=" frame", - colour="yellow", - ) as pbar: - for i, f in enumerate(butter_obj.frames()): - d: float = f.props["_BUTTERAUGLI_3Norm"] - butter_distance_list.append(d) - pbar.update(1) - if not i % 24: - dis: float = sum(butter_distance_list) / len(butter_distance_list) - pbar.set_postfix( - { - "dis": f"{dis:.2f}", - } - ) - - self.butter_dis = sum(butter_distance_list) / len(butter_distance_list) - self.butter_mds = max(butter_distance_list) + score_match = re.search(r"Video Score:\s*(\d+\.\d+)", output) + + if score_match: + self.cvvdp = float(score_match.group(1)) + print(f"({self.cvvdp:.3f})") def calculate_ffmpeg_metrics(self, src: CoreVideo) -> None: """ @@ -294,234 +281,185 @@ def print_ssimulacra2(self) -> None: """ Print SSIMULACRA2 scores. """ - print( - f"\033[94mSSIMULACRA2\033[0m scores for every \033[95m{self.e}\033[0m frame:" - ) - print(f" Average: \033[1m{self.ssimu2_avg:.5f}\033[0m") - print(f" Std Deviation: \033[1m{self.ssimu2_sdv:.5f}\033[0m") - print(f" 10th Pctile: \033[1m{self.ssimu2_p10:.5f}\033[0m") + print(f"SSIMULACRA2 Average: \033[1m{self.ssimu2_avg:.5f}\033[0m") + print(f"SSIMULACRA2 Std Dev: {self.ssimu2_sdv:.5f}") + print(f"SSIMULACRA2 5th %: {self.ssimu2_p05:.5f}") def print_butteraugli(self) -> None: """ Print Butteraugli scores. """ - print( - f"\033[93mButteraugli\033[0m scores for every \033[95m{self.e}\033[0m frame:" - ) - print(f" Distance: \033[1m{self.butter_dis:.5f}\033[0m") - print(f" Max Distance: \033[1m{self.butter_mds:.5f}\033[0m") + print(f"Butteraugli Distance: \033[1m{self.butter_3nm:.5f}\033[0m") + + def print_cvvdp(self) -> None: + """ + Print CVVDP scores. + """ + print(f"CVVDP: \033[1m{self.cvvdp:.5f}\033[0m") def print_ffmpeg_metrics(self) -> None: """ Print XPSNR, SSIM, PSNR, VMAF & VMAF-NEG scores. """ - print("\033[91mXPSNR\033[0m scores:") - print(f" XPSNR: \033[1m{self.xpsnr_y:.5f}\033[0m") - print(f" W-XPSNR: \033[1m{self.w_xpsnr:.5f}\033[0m") - print( - f"\033[38;5;208mVMAF\033[0m scores for every \033[95m{self.e}\033[0m frame:" - ) - print(f" VMAF NEG: \033[1m{self.vmaf_neg_hmn:.5f}\033[0m") - print(f" VMAF: \033[1m{self.vmaf:.5f}\033[0m") - print(f"SSIM Score: \033[1m{self.ssim:.5f}\033[0m") - print(f"PSNR Score: \033[1m{self.psnr:.5f}\033[0m") - print("AVG VMAF/SSIM/PSNR score:") - print(f" \033[1m{self.svt_triple:.5f}\033[0m") + print(f"W-XPSNR: \033[1m{self.w_xpsnr:.5f}\033[0m") + print(f"VMAF NEG (Harmonic Mean): \033[1m{self.vmaf_neg_hmn:.5f}\033[0m") + print(f"VMAF: \033[1m{self.vmaf:.5f}\033[0m") + print(f"SSIM: \033[1m{self.ssim:.5f}\033[0m") + print(f"PSNR: \033[1m{self.psnr:.5f}\033[0m") + print(f"SVT Triple: \033[1m{self.svt_triple:.5f}\033[0m") class VideoEnc: """ - Video encoding class, containing encoder commands. + Video encoder class. """ src: CoreVideo - dst_pth: str q: int - encoder: str - encoder_args: list[str] - enc_cmd: list[str] + enc: str + enc_args: list[str] + dst_pth: str time: float def __init__( self, src: CoreVideo, q: int, - encoder: str, - encoder_args: list[str], - dst_pth: str = "", + enc: str, + enc_args: list[str], + dst_pth: str | None = None, ) -> None: self.src = src - self.dst_pth = dst_pth self.q = q - self.encoder = encoder - self.encoder_args = encoder_args if encoder_args else [""] - self.enc_cmd = self.set_enc_cmd() - self.time = 0 + self.enc = enc + self.enc_args = enc_args + if dst_pth: + self.dst_pth = dst_pth + else: + ext = { + "x264": "mp4", + "x265": "mp4", + "svtav1": "ivf", + "aomenc": "ivf", + "vpxenc": "webm", + }.get(enc, "mp4") + self.dst_pth = f"{os.path.splitext(src.name)[0]}_{q}.{ext}" def set_enc_cmd(self) -> list[str]: - p: str = os.path.splitext(os.path.basename(self.src.path))[0] - if not self.dst_pth: - match self.encoder: - case "x264": - self.dst_pth = f"./{p}_{self.encoder}_q{self.q}.264" - case "x265": - self.dst_pth = f"./{p}_{self.encoder}_q{self.q}.265" - case "vvenc": - self.dst_pth = f"./{p}_{self.encoder}_q{self.q}.266" - case "vpxenc": - self.dst_pth = f"./{p}_{self.encoder}_q{self.q}.ivf" - case _: - self.dst_pth = f"./{p}_{self.encoder}_q{self.q}.ivf" - else: - match self.encoder: - case "x264": - self.dst_pth = self.dst_pth + ".264" - case "x265": - self.dst_pth = self.dst_pth + ".265" - case "vvenc": - self.dst_pth = self.dst_pth + ".266" - case "vpxenc": - self.dst_pth = self.dst_pth + ".ivf" - case _: - self.dst_pth = self.dst_pth + ".ivf" - - match self.encoder: - case "x264": - cmd: list[str] = [ - "x264", - "--demuxer", - "y4m", - "--crf", - f"{self.q}", - "-o", - f"{self.dst_pth}", - "-", - ] - case "x265": - cmd: list[str] = [ - "x265", - "--y4m", - "-", - "--crf", - f"{self.q}", - "-o", - f"{self.dst_pth}", - ] - case "vvenc": - cmd: list[str] = [ - "vvencapp", - "--y4m", + """ + Set the encoder command based on the encoder choice. + """ + cmd: list[str] = [] + if self.enc == "x264": + cmd = ( + [ + "ffmpeg", + "-y", "-i", - "-", - "--qp", - f"{self.q}", - "-o", - f"{self.dst_pth}", + self.src.path, + "-an", + "-c:v", + "libx264", + "-crf", + str(self.q), ] - case "svtav1": - cmd: list[str] = [ - "SvtAv1EncApp", + + self.enc_args + + [self.dst_pth] + ) + elif self.enc == "x265": + cmd = ( + [ + "ffmpeg", + "-y", "-i", - "-", - "-b", - f"{self.dst_pth}", - "--crf", - f"{self.q}", + self.src.path, + "-an", + "-c:v", + "libx265", + "-crf", + str(self.q), ] - case "vpxenc": - cmd: list[str] = [ - "vpxenc", - "--codec=vp9", - "--ivf", - "--end-usage=q", - "--bit-depth=10", - "--input-bit-depth=10", - "--profile=2", - "--passes=1", - f"--cq-level={self.q}", + + self.enc_args + + [self.dst_pth] + ) + elif self.enc == "svtav1": + # SvtAv1EncApp only accepts y4m input; use a shell pipeline: + # ffmpeg (to y4m) | SvtAv1EncApp -i - -b - | ffmpeg -i - -c copy + enc_args_str = ( + " ".join(shlex.quote(arg) for arg in self.enc_args) + if self.enc_args + else "" + ) + ffmpeg_in = f"ffmpeg -hide_banner -loglevel error -i {shlex.quote(self.src.path)} -an -pix_fmt yuv420p10le -strict -2 -f yuv4mpegpipe -" + svt_cmd = f"SvtAv1EncApp -i - --rc 0 --crf {shlex.quote(str(self.q))} -b - {enc_args_str} --progress 3" + ffmpeg_out = f"ffmpeg -y -hide_banner -loglevel error -i - -c copy {shlex.quote(self.dst_pth)}" + # Return a shell string pipeline; encode() will run it with shell=True + cmd = f"{ffmpeg_in} | {svt_cmd} | {ffmpeg_out}" + elif self.enc == "aomenc": + cmd = ( + [ + "ffmpeg", + "-y", + "-i", + self.src.path, + "-an", + "-c:v", + "libaom-av1", + "-crf", + str(self.q), + "-b:v", + "0", ] - case _: # "aomenc": - cmd: list[str] = [ - "aomenc", - "--ivf", - "--end-usage=q", - f"--cq-level={self.q}", - "--passes=1", + + self.enc_args + + [self.dst_pth] + ) + elif self.enc == "vpxenc": + cmd = ( + [ + "ffmpeg", "-y", - "-", - "-o", - f"{self.dst_pth}", + "-i", + self.src.path, + "-an", + "-c:v", + "libvpx-vp9", + "-crf", + str(self.q), + "-b:v", + "0", ] - - if self.encoder_args != [""]: - cmd.extend(self.encoder_args) - if self.encoder == "vpxenc": - extra_args: list[str] = ["-o", f"{self.dst_pth}", "-"] - cmd.extend(extra_args) - print(" ".join(cmd)) + + self.enc_args + + [self.dst_pth] + ) return cmd - def encode(self, e: int, t: int, g: int) -> DstVideo: + def encode(self, every: int, threads: int, gpu_streams: int) -> DstVideo: """ - Encode the video using FFmpeg piped to your chosen encoder. + Run the encoder and return a DstVideo object. """ - ff_cmd: list[str] = [ - "ffmpeg", - "-hide_banner", - "-y", - "-loglevel", - "error", - "-i", - f"{self.src.path}", - "-pix_fmt", - "yuv420p10le", - "-strict", - "-2", - "-f", - "yuv4mpegpipe", - "-", - ] - print( - f"Encoding {self.src.name} at Q{self.q} with {self.encoder} ({self.src.path} --> {self.dst_pth})" - ) - ff_proc: Popen[bytes] = subprocess.Popen( - ff_cmd, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - ) - start_time: float = time.time() - enc_proc: Popen[str] = subprocess.Popen( - self.enc_cmd, - stdin=ff_proc.stdout, - stdout=subprocess.PIPE, - stderr=subprocess.PIPE, - universal_newlines=True, - ) - _, stderr = enc_proc.communicate() - encode_time: float = time.time() - start_time - self.time = encode_time - print(stderr) - return DstVideo(self.dst_pth, e, t, g) + cmd = self.set_enc_cmd() + print(f"Encoding with {self.enc} (CRF {self.q})...") + start_time = time.time() + # SvtAv1EncApp uses a shell pipeline string; run via shell=True in that case. + if self.enc == "svtav1": + subprocess.run(cmd, check=True, capture_output=True, shell=True) + else: + subprocess.run(cmd, check=True, capture_output=True) + self.time = time.time() - start_time + return DstVideo(self.dst_pth, every, threads, gpu_streams) def remove_output(self) -> None: """ - Remove the output file. + Remove the output video file. """ - os.remove(self.dst_pth) + if os.path.exists(self.dst_pth): + os.remove(self.dst_pth) def psnr_to_mse(p: float, m: int) -> float: """ Convert PSNR to MSE (Mean Squared Error). Used in weighted XPSNR calculation. """ + if p <= 0: + return float(m**2) return (m**2) / (10 ** (p / 10)) - - -def calc_some_scores(score_list: list[float]) -> tuple[float, float, float]: - """ - Calculate the average, standard deviation, & 10th percentile of a list of scores. - """ - average: float = statistics.mean(score_list) - std_dev: float = statistics.stdev(score_list) - percentile_10th: float = statistics.quantiles(score_list, n=100)[10] - return (average, std_dev, percentile_10th) diff --git a/scripts/plot.py b/scripts/plot.py index 3f2f80b..c48856c 100755 --- a/scripts/plot.py +++ b/scripts/plot.py @@ -25,7 +25,7 @@ def read_csv(filename): """ Read CSV file and return data as dictionary of lists. Assumes CSV columns: 'q', 'encode_time', 'output_filesize', - 'ssimu2_mean', 'butter_distance', 'wxpsnr', 'vmaf_neg', + 'ssimu2_mean', 'butter_distance', 'cvvdp_mean', 'wxpsnr', 'vmaf_neg', 'vmaf', 'ssim', and 'psnr'. """ data = { @@ -34,6 +34,7 @@ def read_csv(filename): "output_filesize": [], "ssimu2_mean": [], "butter_distance": [], + "cvvdp_mean": [], "wxpsnr": [], "vmaf_neg": [], "vmaf": [], @@ -45,7 +46,8 @@ def read_csv(filename): reader = csv.DictReader(csvfile) for row in reader: for key in data: - data[key].append(float(row[key])) + if key in row: + data[key].append(float(row[key])) return data @@ -63,6 +65,7 @@ def bdrate_vs_time_csv( "avg_encode_time", "ssimu2_mean_bd", "butter_distance_bd", + "cvvdp_mean_bd", "wxpsnr_bd", "vmaf_neg_bd", "vmaf_bd", @@ -74,14 +77,14 @@ def bdrate_vs_time_csv( # Append to existing file with open(csv_file, "a") as f: f.write( - f"{name},{time:.5f},{bd_rates.get('ssimu2_mean', 0):.5f},{bd_rates.get('butter_distance', 0):.5f},{bd_rates.get('wxpsnr', 0):.5f},{bd_rates.get('vmaf_neg', 0):.5f},{bd_rates.get('vmaf', 0):.5f},{bd_rates.get('ssim', 0):.5f},{bd_rates.get('psnr', 0):.5f}\n" + f"{name},{time:.5f},{bd_rates.get('ssimu2_mean', 0):.5f},{bd_rates.get('butter_distance', 0):.5f},{bd_rates.get('cvvdp_mean', 0):.5f},{bd_rates.get('wxpsnr', 0):.5f},{bd_rates.get('vmaf_neg', 0):.5f},{bd_rates.get('vmaf', 0):.5f},{bd_rates.get('ssim', 0):.5f},{bd_rates.get('psnr', 0):.5f}\n" ) else: # Create new file with headers with open(csv_file, "w") as f: f.write(",".join(headers) + "\n") f.write( - f"{name},{time:.5f},{bd_rates.get('ssimu2_mean', 0):.5f},{bd_rates.get('butter_distance', 0):.5f},{bd_rates.get('wxpsnr', 0):.5f},{bd_rates.get('vmaf_neg', 0):.5f},{bd_rates.get('vmaf', 0):.5f},{bd_rates.get('ssim', 0):.5f},{bd_rates.get('psnr', 0):.5f}\n" + f"{name},{time:.5f},{bd_rates.get('ssimu2_mean', 0):.5f},{bd_rates.get('butter_distance', 0):.5f},{bd_rates.get('cvvdp_mean', 0):.5f},{bd_rates.get('wxpsnr', 0):.5f},{bd_rates.get('vmaf_neg', 0):.5f},{bd_rates.get('vmaf', 0):.5f},{bd_rates.get('ssim', 0):.5f},{bd_rates.get('psnr', 0):.5f}\n" ) @@ -104,6 +107,7 @@ def create_metric_plot(datasets, metric_name: str, fmt: str): colormaps = { "ssimu2_mean": plt.colormaps.get_cmap("Blues"), "butter_distance": plt.colormaps.get_cmap("YlOrBr"), + "cvvdp_mean": plt.colormaps.get_cmap("Greens"), "wxpsnr": plt.colormaps.get_cmap("Reds"), "vmaf_neg": plt.colormaps.get_cmap("RdGy"), "vmaf": plt.colormaps.get_cmap("autumn"), @@ -148,6 +152,7 @@ def create_metric_plot(datasets, metric_name: str, fmt: str): metric_labels = { "ssimu2_mean": "Average SSIMULACRA2", "butter_distance": "Butteraugli Distance", + "cvvdp_mean": "Average CVVDP", "wxpsnr": "W-XPSNR", "vmaf_neg": "VMAF NEG (Harmonic Mean)", "vmaf": "VMAF", @@ -234,6 +239,8 @@ def calculate_average_encode_time(data): Calculate the average encode time from the data. """ encode_times = data["encode_time"] + if not encode_times: + return 0.0 return sum(encode_times) / len(encode_times) @@ -271,6 +278,7 @@ def main(): metrics = [ "ssimu2_mean", "butter_distance", + "cvvdp_mean", "wxpsnr", "vmaf_neg", "vmaf", @@ -278,7 +286,9 @@ def main(): "psnr", ] for metric in metrics: - create_metric_plot(datasets, metric, fmt) + # Only plot if the metric exists in the datasets + if any(metric in d[1] and d[1][metric] for d in datasets): + create_metric_plot(datasets, metric, fmt) # Calculate and output the average encode time for each CSV file for label, data in datasets: @@ -290,6 +300,7 @@ def main(): metric_labels = { "ssimu2_mean": "\033[94mSSIMULACRA2\033[0m Average: ", "butter_distance": "\033[93mButteraugli\033[0m Distance: ", + "cvvdp_mean": "\033[92mCVVDP\033[0m Average: ", "wxpsnr": "W-\033[91mXPSNR\033[0m: ", "vmaf_neg": "\033[38;5;208mVMAF NEG\033[0m (Harmonic Mean): ", "vmaf": "\033[38;5;208mVMAF\033[0m: ", @@ -312,6 +323,15 @@ def main(): # Create lists of tuples (output_filesize, metric_value) for the two files. data1 = datasets[0][1] data2 = datasets[idx][1] + + if ( + metric not in data1 + or metric not in data2 + or not data1[metric] + or not data2[metric] + ): + continue + metric_set1 = list(zip(data1["output_filesize"], data1[metric])) metric_set2 = list(zip(data2["output_filesize"], data2[metric])) diff --git a/scripts/ruff.sh b/scripts/ruff.sh new file mode 100755 index 0000000..73a4918 --- /dev/null +++ b/scripts/ruff.sh @@ -0,0 +1,5 @@ +#!/bin/bash -eu + +ruff format scripts/*.py +ruff check --select I --fix scripts/*.py +ruff check --fix scripts/*.py diff --git a/scripts/scores.py b/scripts/scores.py index 1812d13..841d829 100755 --- a/scripts/scores.py +++ b/scripts/scores.py @@ -5,8 +5,6 @@ # "argparse>=1.4.0", # "statistics>=1.0.3.5", # "tqdm>=4.67.1", -# "vapoursynth>=70", -# "vstools>=3.3.4", # ] # /// @@ -34,14 +32,14 @@ def main() -> None: "--gpu-streams", type=int, default=0, - help="Number of GPU streams for SSIMULACRA2/Butteraugli", + help="Number of FFVship GPU threads (SSIMULACRA2/Butteraugli/CVVDP)", ) parser.add_argument( "-t", "--threads", type=int, - default=0, - help="Number of threads for SSIMULACRA2/Butteraugli", + default=2, + help="Number of decoder threads for FFVship. Default 2", ) args: Namespace = parser.parse_args() @@ -60,11 +58,12 @@ def main() -> None: v.print_ssimulacra2() # Calculate Butteraugli scores - if gpu_streams: - v.calculate_butteraugli(s) - v.print_butteraugli() - else: - v.calculate_butteraugli(s) + v.calculate_butteraugli(s) + v.print_butteraugli() + + # Calculate CVVDP scores + v.calculate_cvvdp(s) + v.print_cvvdp() # Calculate XPSNR scores v.calculate_ffmpeg_metrics(s) diff --git a/scripts/stats.py b/scripts/stats.py index 23c2726..2da7184 100755 --- a/scripts/stats.py +++ b/scripts/stats.py @@ -4,9 +4,6 @@ # dependencies = [ # "argparse>=1.4.0", # "statistics>=1.0.3.5", -# "tqdm>=4.67.1", -# "vapoursynth>=70", -# "vstools>=3.3.4", # ] # /// @@ -24,6 +21,7 @@ def write_stats( size: int, ssimu2_mean: float, butter_distance: float, + cvvdp_mean: float, w_xpsnr: float, vmaf_neg: float, vmaf: float, @@ -38,15 +36,15 @@ def write_stats( if not os.path.exists(csv): with open(csv, "w") as f: f.write( - "q,encode_time,output_filesize,ssimu2_mean,butter_distance,wxpsnr,vmaf_neg,vmaf,ssim,psnr\n" + "q,encode_time,output_filesize,ssimu2_mean,butter_distance,cvvdp_mean,wxpsnr,vmaf_neg,vmaf,ssim,psnr\n" ) f.write( - f"{q},{encode_time:.5f},{size},{ssimu2_mean:.5f},{butter_distance:.5f},{w_xpsnr:.5f},{vmaf_neg:.5f},{vmaf:.5f},{ssim:.5f},{psnr:.5f}\n" + f"{q},{encode_time:.5f},{size},{ssimu2_mean:.5f},{butter_distance:.5f},{cvvdp_mean:.5f},{w_xpsnr:.5f},{vmaf_neg:.5f},{vmaf:.5f},{ssim:.5f},{psnr:.5f}\n" ) else: with open(csv, "a") as f: f.write( - f"{q},{encode_time:.5f},{size},{ssimu2_mean:.5f},{butter_distance:.5f},{w_xpsnr:.5f},{vmaf_neg:.5f},{vmaf:.5f},{ssim:.5f},{psnr:.5f}\n" + f"{q},{encode_time:.5f},{size},{ssimu2_mean:.5f},{butter_distance:.5f},{cvvdp_mean:.5f},{w_xpsnr:.5f},{vmaf_neg:.5f},{vmaf:.5f},{ssim:.5f},{psnr:.5f}\n" ) @@ -90,14 +88,14 @@ def main(): "--gpu-streams", type=int, default=0, - help="Number of GPU streams for SSIMULACRA2/Butteraugli", + help="Number of FFVship GPU threads (SSIMULACRA2/Butteraugli/CVVDP)", ) parser.add_argument( "-t", "--threads", type=int, - default=0, - help="Number of threads for SSIMULACRA2/Butteraugli", + default=2, + help="Number of decoder threads for FFVship. Default 2", ) parser.add_argument( "-k", @@ -120,7 +118,7 @@ def main(): csv_out: str = args.output every: int = args.every threads: int = args.threads - gpu_streams: bool = args.gpu_streams + gpu_streams: int = args.gpu_streams clean: bool = args.keep enc_args: list[str] = args.encoder_args @@ -136,6 +134,9 @@ def main(): cumulative_butter: list[dict[int, float]] = [ {q: 0.0 for q in quality_list} for _ in range(len(src_pth)) ] + cumulative_cvvdp: list[dict[int, float]] = [ + {q: 0.0 for q in quality_list} for _ in range(len(src_pth)) + ] cumulative_wxpsnr: list[dict[int, float]] = [ {q: 0.0 for q in quality_list} for _ in range(len(src_pth)) ] @@ -166,12 +167,14 @@ def main(): v.calculate_ssimulacra2(s) v.calculate_butteraugli(s) + v.calculate_cvvdp(s) v.calculate_ffmpeg_metrics(s) cumulative_times[i][q] = e.time cumulative_sizes[i][q] = v.size cumulative_ssimu2[i][q] = v.ssimu2_avg - cumulative_butter[i][q] = v.butter_dis + cumulative_butter[i][q] = v.butter_3nm + cumulative_cvvdp[i][q] = v.cvvdp cumulative_wxpsnr[i][q] = v.w_xpsnr cumulative_vmafneg[i][q] = v.vmaf_neg_hmn cumulative_vmaf[i][q] = v.vmaf @@ -186,6 +189,7 @@ def main(): avg_size: dict[int, int] = {} avg_ssimu2: dict[int, float] = {} avg_butter: dict[int, float] = {} + avg_cvvdp: dict[int, float] = {} avg_wxpsnr: dict[int, float] = {} avg_vmafneg: dict[int, float] = {} avg_vmaf: dict[int, float] = {} @@ -197,6 +201,7 @@ def main(): avg_size[q] = int(sum(cumulative_sizes[j][q] for j in range(i)) / i) avg_ssimu2[q] = sum(cumulative_ssimu2[j][q] for j in range(i)) / i avg_butter[q] = sum(cumulative_butter[j][q] for j in range(i)) / i + avg_cvvdp[q] = sum(cumulative_cvvdp[j][q] for j in range(i)) / i avg_wxpsnr[q] = sum(cumulative_wxpsnr[j][q] for j in range(i)) / i avg_vmafneg[q] = sum(cumulative_vmafneg[j][q] for j in range(i)) / i avg_vmaf[q] = sum(cumulative_vmaf[j][q] for j in range(i)) / i @@ -209,6 +214,7 @@ def main(): avg_size[q], avg_ssimu2[q], avg_butter[q], + avg_cvvdp[q], avg_wxpsnr[q], avg_vmafneg[q], avg_vmaf[q],