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],