ffmpeg_quality_metrics

 1import importlib.metadata
 2
 3from .ffmpeg_quality_metrics import (
 4    FfmpegQualityMetrics,
 5    FfmpegQualityMetricsError,
 6    GlobalStats,
 7    GlobalStatsData,
 8    InputInfo,
 9    MetricData,
10    MetricName,
11    SingleMetricData,
12    VmafOptions,
13)
14
15__version__ = importlib.metadata.version("ffmpeg-quality-metrics")
16__all__ = [
17    "FfmpegQualityMetrics",
18    "FfmpegQualityMetricsError",
19    "VmafOptions",
20    "MetricName",
21    "SingleMetricData",
22    "GlobalStatsData",
23    "GlobalStats",
24    "MetricData",
25    "InputInfo",
26    "__version__",
27]
class FfmpegQualityMetrics:
 111class FfmpegQualityMetrics:
 112    """
 113    A class to calculate quality metrics with FFmpeg
 114    """
 115
 116    ALLOWED_SCALERS = [
 117        "fast_bilinear",
 118        "bilinear",
 119        "bicubic",
 120        "experimental",
 121        "neighbor",
 122        "area",
 123        "bicublin",
 124        "gauss",
 125        "sinc",
 126        "lanczos",
 127        "spline",
 128    ]
 129    DEFAULT_SCALER = "bicubic"
 130    DEFAULT_THREADS = 0
 131
 132    DEFAULT_VMAF_THREADS = 0  # used to be os.cpu_count(), now auto
 133    DEFAULT_VMAF_SUBSAMPLE = 1  # sample every frame
 134    DEFAULT_VMAF_MODEL_DIRECTORY = os.path.join(
 135        os.path.dirname(__file__), "vmaf_models"
 136    )
 137    DEFAULT_VMAF_OPTIONS: VmafOptions = {
 138        "model_path": None,
 139        "model_params": [],
 140        "n_threads": DEFAULT_VMAF_THREADS,
 141        "n_subsample": DEFAULT_VMAF_SUBSAMPLE,
 142        "features": [],
 143        "ten_bit": False,
 144    }
 145    POSSIBLE_FILTERS: List[FilterName] = [
 146        "libvmaf",
 147        "psnr",
 148        "ssim",
 149        "vif",
 150        "msad",
 151    ]
 152    METRIC_TO_FILTER_MAP: Dict[MetricName, FilterName] = {
 153        "vmaf": "libvmaf",
 154        "psnr": "psnr",
 155        "ssim": "ssim",
 156        "vif": "vif",
 157        "msad": "msad",
 158    }
 159    SUMMARY_SUBMETRICS: Dict[MetricName, List[str]] = {
 160        "psnr": ["psnr_avg"],
 161        "ssim": ["ssim_avg"],
 162        "vmaf": ["vmaf"],
 163        "vif": ["scale_0", "scale_1", "scale_2", "scale_3"],
 164        "msad": ["msad_avg"],
 165    }
 166    """Main submetrics of each metric, shown in the summary from `get_summary()`."""
 167
 168    def __init__(
 169        self,
 170        ref: str,
 171        dist: str,
 172        scaling_algorithm: str = DEFAULT_SCALER,
 173        framerate: Union[float, None] = None,
 174        dist_delay: float = 0,
 175        dry_run: Union[bool, None] = False,
 176        verbose: Union[bool, None] = False,
 177        threads: int = DEFAULT_THREADS,
 178        progress: Union[bool, None] = False,
 179        keep_tmp_files: Union[bool, None] = False,
 180        tmp_dir: Union[str, None] = None,
 181        num_frames: Union[int, None] = None,
 182        start_offset: Union[str, None] = None,
 183        ffmpeg_path: str = "ffmpeg",
 184    ):
 185        """Instantiate a new FfmpegQualityMetrics
 186
 187        Args:
 188            ref (str): reference file
 189            dist (str): distorted file
 190            scaling_algorithm (str, optional): A scaling algorithm. Must be one of the following: ["fast_bilinear", "bilinear", "bicubic", "experimental", "neighbor", "area", "bicublin", "gauss", "sinc", "lanczos", "spline"]. Defaults to "bicubic"
 191            framerate (float, optional): Force a frame rate. Defaults to None.
 192            dist_delay (float): Temporally align the distorted file against the reference by this many seconds, by trimming the leading unmatched frames of one stream before comparison. A positive value means the distorted stream starts this many seconds after the reference (the reference's leading frames are trimmed); a negative value means the distorted stream leads (its leading frames are trimmed). Defaults to 0.
 193            dry_run (bool, optional): Don't run anything, just print commands. Defaults to False.
 194            verbose (bool, optional): Show more output. Defaults to False.
 195            threads (int, optional): Number of ffmpeg threads. Defaults to 0 (auto).
 196            progress (bool, optional): Show a progress bar. Defaults to False.
 197            keep_tmp_files (bool, optional): Keep temporary files for debugging purposes. Defaults to False.
 198            tmp_dir (str, optional): Directory to store temporary files. Will use system default if not specified. Defaults to None.
 199            num_frames (int, optional): Number of frames to analyze from the input files. Defaults to None (all frames).
 200            start_offset (str, optional): Seek to this position before analyzing. Accepts timestamp (e.g., '00:00:10' or '10.5') or frame number with 'f:' prefix (e.g., 'f:100'). Defaults to None.
 201            ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
 202
 203        Raises:
 204            FfmpegQualityMetricsError: A generic error
 205        """
 206        self.ref = str(ref)
 207        self.dist = str(dist)
 208        self.scaling_algorithm = str(scaling_algorithm)
 209        self.framerate = float(framerate) if framerate is not None else None
 210        self.dist_delay = float(dist_delay)
 211        self.dry_run = bool(dry_run)
 212        self.verbose = bool(verbose)
 213        self.threads = int(threads)
 214        self.progress = bool(progress)
 215        self.keep_tmp_files = bool(keep_tmp_files)
 216        self.tmp_dir = str(tmp_dir) if tmp_dir is not None else tempfile.gettempdir()
 217        self.num_frames = int(num_frames) if num_frames is not None else None
 218        self.start_offset = str(start_offset) if start_offset is not None else None
 219        self.ffmpeg_path = ffmpeg_path
 220
 221        if not os.path.isfile(self.ref):
 222            raise FfmpegQualityMetricsError(f"Reference file not found: {self.ref}")
 223        if not os.path.isfile(self.dist):
 224            raise FfmpegQualityMetricsError(f"Distorted file not found: {self.dist}")
 225
 226        if self.ref == self.dist:
 227            logger.warning(
 228                "Reference and distorted files are the same! This may lead to unexpected results or numerical issues."
 229            )
 230
 231        if ref.endswith(".yuv") or dist.endswith(".yuv"):
 232            raise FfmpegQualityMetricsError(
 233                "YUV files are not supported, please convert to a format that ffmpeg can read natively, such as Y4M or FFV1."
 234            )
 235
 236        self.data: MetricData = {
 237            "vmaf": [],
 238            "psnr": [],
 239            "ssim": [],
 240            "vif": [],
 241            "msad": [],
 242        }
 243
 244        self.available_filters: List[str] = []
 245
 246        self.global_stats: GlobalStats = {}
 247
 248        self.input_info: Union[Tuple[InputInfo, InputInfo], None] = None
 249        """Properties of the reference and distorted file, set by `check_inputs()`."""
 250        self._inputs_checked = False
 251
 252        if not os.path.isdir(self.tmp_dir):
 253            logger.debug(f"Creating temporary directory: {self.tmp_dir}")
 254            os.makedirs(self.tmp_dir)
 255        self.temp_files: Dict[FilterName, str] = {}
 256
 257        for filter_name in self.POSSIBLE_FILTERS:
 258            suffix = "txt" if filter_name != "libvmaf" else "json"
 259
 260            self.temp_files[filter_name] = os.path.join(
 261                self.tmp_dir,
 262                f"ffmpeg_quality_metrics_{filter_name}_{os.path.basename(self.ref)}_{os.path.basename(self.dist)}.{suffix}",
 263            )
 264            logger.debug(
 265                f"Writing temporary {filter_name.upper()} information to: {self.temp_files[filter_name]}"
 266            )
 267
 268        if scaling_algorithm not in self.ALLOWED_SCALERS:
 269            raise FfmpegQualityMetricsError(
 270                f"Allowed scaling algorithms: {self.ALLOWED_SCALERS}"
 271            )
 272
 273        self._check_available_filters()
 274
 275    def _check_available_filters(self):
 276        """
 277        Check which filters are available
 278        """
 279        cmd = [self.ffmpeg_path, "-filters"]
 280        try:
 281            stdout, _ = run_command(cmd)
 282        except OSError as e:
 283            raise FfmpegQualityMetricsError(
 284                f"Could not run ffmpeg at '{self.ffmpeg_path}': {e.strerror}. "
 285                "Check the path to ffmpeg (--ffmpeg-path)."
 286            ) from e
 287        filter_list = []
 288        for line in stdout.split("\n"):
 289            line = line.strip()
 290            if line == "":
 291                continue
 292            cols = line.split(" ")
 293            if len(cols) > 1:
 294                filter_name = cols[1]
 295                filter_list.append(filter_name)
 296
 297        for key in FfmpegQualityMetrics.POSSIBLE_FILTERS:
 298            if key in filter_list:
 299                self.available_filters.append(key)
 300
 301        logger.debug(f"Available filters: {self.available_filters}")
 302
 303    @staticmethod
 304    def get_framerate(input_file: str, ffmpeg_path: str = "ffmpeg") -> float:
 305        """Parse the FPS from the input file.
 306
 307        First parse it from the ffmpeg banner ("<n> fps"). Some container/codec
 308        combinations (e.g. FFV1 in a .nut) do not print an fps token in the
 309        banner (only "tbr"/"tbn"), so fall back to querying ffprobe for the
 310        exact rational frame rate. ffprobe ships with ffmpeg, so this adds no
 311        new dependency.
 312
 313        Args:
 314            input_file (str): Input file path
 315            ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
 316
 317        Raises:
 318            FfmpegQualityMetricsError: A generic error
 319
 320        Returns:
 321            float: The FPS parsed
 322        """
 323        cmd = [ffmpeg_path, "-nostdin", "-y", "-i", input_file]
 324
 325        output = run_command(cmd, allow_error=True)
 326        pattern = re.compile(r"(\d+(\.\d+)?) fps")
 327        try:
 328            if pattern_ret := pattern.search(str(output)):
 329                match = pattern_ret.groups()[0]
 330                return float(match)
 331        except Exception:
 332            pass
 333
 334        # Fallback: the ffmpeg banner did not contain an "<n> fps" token (this
 335        # happens for some container/codec combinations, e.g. FFV1 in .nut,
 336        # which only report "tbr"/"tbn"). Query ffprobe for the exact rational
 337        # frame rate instead of failing. Prefer r_frame_rate, then
 338        # avg_frame_rate; skip missing/"0/0" values.
 339        ffprobe_path = get_ffprobe_path(ffmpeg_path)
 340        for field in ("r_frame_rate", "avg_frame_rate"):
 341            try:
 342                stdout, _ = run_command(
 343                    [
 344                        ffprobe_path,
 345                        "-loglevel",
 346                        "error",
 347                        "-select_streams",
 348                        "v:0",
 349                        "-show_entries",
 350                        f"stream={field}",
 351                        "-of",
 352                        "csv=p=0",
 353                        input_file,
 354                    ],
 355                    allow_error=True,
 356                )
 357            except Exception:
 358                continue
 359            value = stdout.strip().splitlines()[0].strip() if stdout.strip() else ""
 360            if not value or value == "0/0":
 361                continue
 362            try:
 363                frac = Fraction(value)
 364            except (ValueError, ZeroDivisionError):
 365                continue
 366            if frac > 0:
 367                return float(frac)
 368
 369        raise FfmpegQualityMetricsError(f"could not parse FPS from file {input_file}!")
 370
 371    def _get_framerates(self) -> Tuple[float, float]:
 372        """
 373        Get the framerates of the reference and distorted files.
 374
 375        Returns:
 376            Tuple[float, float]: The framerates of the reference and distorted files
 377        """
 378        ref_framerate = FfmpegQualityMetrics.get_framerate(self.ref, self.ffmpeg_path)
 379        dist_framerate = FfmpegQualityMetrics.get_framerate(self.dist, self.ffmpeg_path)
 380
 381        # check_inputs() already warns about differing frame rates
 382        if ref_framerate != dist_framerate and self.input_info is None:
 383            logger.warning(
 384                f"ref, dist framerates differ: {ref_framerate}, {dist_framerate}. "
 385                "This may result in inaccurate quality metrics. Force an input framerate via the -r option."
 386            )
 387
 388        return ref_framerate, dist_framerate
 389
 390    @staticmethod
 391    def probe_input(input_file: str, ffmpeg_path: str = "ffmpeg") -> InputInfo:
 392        """Read the properties of the first video stream of a file with ffprobe.
 393
 394        ffprobe is expected next to the ffmpeg executable, or in the PATH.
 395
 396        Args:
 397            input_file (str): Input file path
 398            ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
 399
 400        Raises:
 401            FfmpegQualityMetricsError: If the file does not exist, cannot be read, or has no video stream
 402            FileNotFoundError: If ffprobe cannot be found
 403
 404        Returns:
 405            InputInfo: The video stream properties
 406        """
 407        if not os.path.isfile(input_file):
 408            raise FfmpegQualityMetricsError(f"File not found: {input_file}")
 409
 410        cmd = [
 411            get_ffprobe_path(ffmpeg_path),
 412            "-loglevel",
 413            "error",
 414            "-select_streams",
 415            "v:0",
 416            "-show_entries",
 417            "stream=width,height,r_frame_rate,avg_frame_rate,duration,nb_frames"
 418            ":format=duration",
 419            "-of",
 420            "json",
 421            input_file,
 422        ]
 423        try:
 424            stdout, _ = run_command(cmd)
 425            probe_data = json.loads(stdout)
 426        except (RuntimeError, ValueError) as e:
 427            # the last line of ffprobe's error output names the problem
 428            detail = str(e).strip().splitlines()[-1] if str(e).strip() else ""
 429            raise FfmpegQualityMetricsError(
 430                f"Could not read file {input_file} with ffprobe: {detail}"
 431            ) from e
 432
 433        streams = probe_data.get("streams") or []
 434        if not streams:
 435            raise FfmpegQualityMetricsError(f"No video stream found in {input_file}")
 436        stream = streams[0]
 437
 438        framerate = None
 439        for field in ("r_frame_rate", "avg_frame_rate"):
 440            try:
 441                frac = Fraction(stream.get(field, ""))
 442            except (ValueError, ZeroDivisionError):
 443                continue
 444            if frac > 0:
 445                framerate = float(frac)
 446                break
 447
 448        duration = None
 449        for value in (
 450            stream.get("duration"),
 451            probe_data.get("format", {}).get("duration"),
 452        ):
 453            try:
 454                duration = float(value)
 455                break
 456            except (TypeError, ValueError):
 457                continue
 458
 459        try:
 460            nb_frames = int(stream["nb_frames"])
 461        except (KeyError, TypeError, ValueError):
 462            nb_frames = None
 463
 464        return {
 465            "path": input_file,
 466            "width": int(stream.get("width", 0)),
 467            "height": int(stream.get("height", 0)),
 468            "framerate": framerate,
 469            "duration": duration,
 470            "nb_frames": nb_frames,
 471        }
 472
 473    def check_inputs(self) -> Union[Tuple[InputInfo, InputInfo], None]:
 474        """Probe both input files and log a warning for each property that differs.
 475
 476        Compares resolution, frame rate, and duration (or frame count). This is run
 477        automatically before the first calculation.
 478
 479        Raises:
 480            FfmpegQualityMetricsError: If an input cannot be read or has no video stream
 481
 482        Returns:
 483            Tuple[InputInfo, InputInfo] or None: The properties of the reference and distorted file,
 484                or None if ffprobe is not available
 485        """
 486        try:
 487            ref_info = FfmpegQualityMetrics.probe_input(self.ref, self.ffmpeg_path)
 488            dist_info = FfmpegQualityMetrics.probe_input(self.dist, self.ffmpeg_path)
 489        except FileNotFoundError:
 490            logger.warning(
 491                f"ffprobe not found at {get_ffprobe_path(self.ffmpeg_path)}, skipping input checks"
 492            )
 493            return None
 494
 495        logger.debug(f"Reference input: {ref_info}")
 496        logger.debug(f"Distorted input: {dist_info}")
 497
 498        self._check_resolution(ref_info, dist_info)
 499        self._check_framerate(ref_info, dist_info)
 500        self._check_duration(ref_info, dist_info)
 501
 502        self.input_info = (ref_info, dist_info)
 503        return self.input_info
 504
 505    def _check_resolution(self, ref_info: InputInfo, dist_info: InputInfo) -> None:
 506        """
 507        Warn if the resolutions differ.
 508        """
 509        ref_res = f"{ref_info['width']}x{ref_info['height']}"
 510        dist_res = f"{dist_info['width']}x{dist_info['height']}"
 511        if ref_res != dist_res:
 512            logger.warning(
 513                f"Resolutions differ: distorted {dist_res}, reference {ref_res}. "
 514                f"The distorted video will be scaled to {ref_res} "
 515                f"({self.scaling_algorithm}) before comparison, "
 516                "which affects the metric values."
 517            )
 518
 519    def _check_framerate(self, ref_info: InputInfo, dist_info: InputInfo) -> None:
 520        """
 521        Warn if the frame rates differ and no frame rate is forced.
 522        """
 523        ref_fps = ref_info["framerate"]
 524        dist_fps = dist_info["framerate"]
 525        if ref_fps is None or dist_fps is None or self.framerate is not None:
 526            return
 527        if not math.isclose(ref_fps, dist_fps, abs_tol=1e-3):
 528            logger.warning(
 529                f"Frame rates differ: distorted {dist_fps:g} fps, reference {ref_fps:g} fps. "
 530                "Frames will not line up in time, so the metrics will be inaccurate. "
 531                "Convert both inputs to the same frame rate, or force one with the -r option."
 532            )
 533
 534    def _check_duration(self, ref_info: InputInfo, dist_info: InputInfo) -> None:
 535        """
 536        Warn if the inputs differ in length, after applying dist_delay.
 537        Skipped if num_frames is set.
 538        """
 539        if self.num_frames is not None:
 540            return
 541
 542        ref_frames = ref_info["nb_frames"]
 543        dist_frames = dist_info["nb_frames"]
 544        ref_fps = ref_info["framerate"]
 545        dist_fps = dist_info["framerate"]
 546        # frame counts are only comparable if frames are paired one by one
 547        same_framerate = self.framerate is not None or (
 548            ref_fps is not None
 549            and dist_fps is not None
 550            and math.isclose(ref_fps, dist_fps, abs_tol=1e-3)
 551        )
 552        if (
 553            self.dist_delay == 0
 554            and same_framerate
 555            and ref_frames is not None
 556            and dist_frames is not None
 557        ):
 558            if ref_frames != dist_frames:
 559                longer = "reference" if ref_frames > dist_frames else "distorted"
 560                logger.warning(
 561                    f"Frame counts differ: distorted {dist_frames}, reference {ref_frames}. "
 562                    f"Only the first {min(ref_frames, dist_frames)} frames are compared; "
 563                    f"the remaining frames of the {longer} video are ignored."
 564                )
 565            return
 566
 567        ref_duration = ref_info["duration"]
 568        dist_duration = dist_info["duration"]
 569        if ref_duration is None or dist_duration is None:
 570            return
 571
 572        # dist_delay trims the start of one of the inputs
 573        ref_duration = max(ref_duration - max(self.dist_delay, 0), 0)
 574        dist_duration = max(dist_duration - max(-self.dist_delay, 0), 0)
 575
 576        # tolerate a difference of one frame
 577        known_fps = [
 578            fps
 579            for fps in (self.framerate, ref_info["framerate"], dist_info["framerate"])
 580            if fps
 581        ]
 582        tolerance = 1 / min(known_fps) if known_fps else 0.05
 583
 584        if abs(ref_duration - dist_duration) > tolerance + 1e-6:
 585            longer = "reference" if ref_duration > dist_duration else "distorted"
 586            delay_note = (
 587                f" (after trimming {abs(self.dist_delay):g} s for the distortion delay)"
 588                if self.dist_delay != 0
 589                else ""
 590            )
 591            logger.warning(
 592                f"Durations differ{delay_note}: distorted {dist_duration:.3f} s, "
 593                f"reference {ref_duration:.3f} s. "
 594                f"Only the first {min(ref_duration, dist_duration):.3f} s are compared; "
 595                f"the rest of the {longer} video is ignored."
 596            )
 597
 598    def _parse_start_offset(self, framerate: float) -> Union[str, None]:
 599        """
 600        Parse the start_offset parameter and convert frame numbers to timestamps if needed.
 601
 602        Args:
 603            framerate (float): The framerate to use for frame-to-timestamp conversion
 604
 605        Returns:
 606            Union[str, None]: The timestamp string for ffmpeg's -ss option, or None if no offset
 607        """
 608        if self.start_offset is None:
 609            return None
 610
 611        # Check if it's a frame number (format: "f:100" or "f:100.5")
 612        if self.start_offset.startswith("f:"):
 613            try:
 614                frame_num = float(self.start_offset[2:])
 615                timestamp = frame_num / framerate
 616                return str(timestamp)
 617            except ValueError:
 618                raise FfmpegQualityMetricsError(
 619                    f"Invalid frame number in start_offset: {self.start_offset}"
 620                )
 621
 622        # Otherwise, assume it's a timestamp string (e.g., "00:00:10" or "10.5")
 623        return self.start_offset
 624
 625    def _get_filter_opts(self, filter_name: FilterName) -> str:
 626        """
 627        Returns:
 628            str: Specific ffmpeg filter options for a chosen metric filter.
 629        """
 630        framesync_opts = "shortest=1:repeatlast=0"
 631
 632        if filter_name in ["ssim", "psnr"]:
 633            return (
 634                f"{filter_name}='{win_path_check(self.temp_files[filter_name])}'"
 635                f":{framesync_opts}"
 636            )
 637        elif filter_name == "libvmaf":
 638            return f"libvmaf='{self._get_libvmaf_filter_opts()}'"
 639        elif filter_name == "vif":
 640            return f"vif={framesync_opts},metadata=mode=print"
 641        elif filter_name == "msad":
 642            return f"msad={framesync_opts},metadata=mode=print"
 643        else:
 644            raise FfmpegQualityMetricsError(f"Unknown filter {filter_name}!")
 645
 646    def _get_metric_filter_chains(
 647        self, metric_name: MetricName, dist_label: str, ref_label: str
 648    ) -> List[str]:
 649        """
 650        Build the filter chain(s) for a single metric, reading from the given
 651        distorted and reference filter graph labels.
 652
 653        Returns:
 654            List[str]: One or more filter chains
 655        """
 656        chains: List[str] = []
 657
 658        # convert both inputs to 10 bit if requested, as recommended for VMAF v1 models
 659        if metric_name == "vmaf" and self.vmaf_options["ten_bit"]:
 660            chains.extend(
 661                [
 662                    f"[{dist_label}]format=yuv420p10le[{dist_label}10b]",
 663                    f"[{ref_label}]format=yuv420p10le[{ref_label}10b]",
 664                ]
 665            )
 666            dist_label += "10b"
 667            ref_label += "10b"
 668
 669        chains.append(
 670            f"[{dist_label}][{ref_label}]{self._get_filter_opts(self.METRIC_TO_FILTER_MAP[metric_name])}"
 671        )
 672        return chains
 673
 674    def calculate(
 675        self,
 676        metrics: Union[List[MetricName], None] = None,
 677        vmaf_options: Union[VmafOptions, None] = None,
 678    ) -> Dict[MetricName, SingleMetricData]:
 679        """Calculate one or more metrics.
 680
 681        Args:
 682            metrics (list, optional): A list of metrics to calculate.
 683                Possible values are ["ssim", "psnr", "vmaf"].
 684                Defaults to ["ssim", "psnr"].
 685            vmaf_options (dict, optional): VMAF-specific options. Uses defaults if not specified.
 686
 687        Raises:
 688            FfmpegQualityMetricsError: In case of an error
 689            e: A generic error
 690
 691        Returns:
 692            dict: A dictionary of per-frame info, with the key being the metric name and the value being a dict of frame numbers ('n') and metric values.
 693        """
 694        if metrics is None:
 695            metrics = ["ssim", "psnr"]
 696        if not metrics:
 697            raise FfmpegQualityMetricsError("No metrics specified!")
 698
 699        # check available metrics
 700        for metric_name in metrics:
 701            filter_name = self.METRIC_TO_FILTER_MAP.get(metric_name, None)
 702            if filter_name not in self.POSSIBLE_FILTERS:
 703                raise FfmpegQualityMetricsError(f"No such metric '{metric_name}'")
 704            if filter_name not in self.available_filters:
 705                raise FfmpegQualityMetricsError(
 706                    f"Your ffmpeg version does not have the filter '{filter_name}'"
 707                )
 708
 709        # probe the inputs once, before running ffmpeg
 710        if not self._inputs_checked:
 711            self.check_inputs()
 712            self._inputs_checked = True
 713
 714        # set VMAF options specifically
 715        if "vmaf" in metrics:
 716            self._check_libvmaf_availability()
 717            self.vmaf_options = self.DEFAULT_VMAF_OPTIONS.copy()
 718            # override with user-supplied options
 719            if vmaf_options:
 720                for key, value in vmaf_options.items():
 721                    if value is not None:
 722                        self.vmaf_options[key] = value  # type: ignore
 723            self._set_vmaf_model_path(self.vmaf_options["model_path"])
 724
 725        # ffmpeg 7.1 or higher: scale2ref filter is deprecated
 726        # input 0: ref, input 1: dist --> swapped for scale filter
 727
 728        # Apply select filter if num_frames is specified
 729        select_filter = ""
 730        if self.num_frames is not None:
 731            select_filter = f"select='lt(n\\,{self.num_frames})',"
 732
 733        # Apply dist_delay by trimming the leading, unmatched portion of one
 734        # stream so the two streams are temporally aligned before comparison.
 735        # libvmaf (and the other metric filters) pair frames sequentially, so a
 736        # PTS-only shift (e.g. -itsoffset) is cancelled by the setpts reset below
 737        # and has no effect. Trimming happens at decode time - no re-encode.
 738        # dist_delay > 0: distorted is delayed against the reference, so the
 739        #   reference has extra leading frames -> trim the reference.
 740        # dist_delay < 0: the distorted stream leads -> trim the distorted.
 741        ref_trim = f"trim=start={self.dist_delay}," if self.dist_delay > 0 else ""
 742        dist_trim = f"trim=start={abs(self.dist_delay)}," if self.dist_delay < 0 else ""
 743
 744        # Align both streams before the reference is split between the scale
 745        # filter and the metric filter. Feeding the untrimmed reference directly
 746        # to scale while separately trimming it for the metric makes ffmpeg
 747        # buffer the entire skipped lead-in on large positive delays. Real
 748        # captures can start tens of seconds into a source, which previously
 749        # grew memory into gigabytes and could be OOM-killed.
 750        filter_chains = [
 751            f"[0]{ref_trim}{select_filter}settb=AVTB,setpts=PTS-STARTPTS[refaligned]",
 752            "[refaligned]split=2[refscale][refpts]",
 753            f"[1]{dist_trim}{select_filter}settb=AVTB,setpts=PTS-STARTPTS[distaligned]",
 754            f"[distaligned][refscale]scale=rw:rh:flags={self.scaling_algorithm}[distpts]",
 755        ]
 756
 757        # generate split filters depending on the number of models
 758        n_splits = len(metrics)
 759        if n_splits > 1:
 760            for source in ["dist", "ref"]:
 761                suffixes = "".join([f"[{source}{n}]" for n in range(1, n_splits + 1)])
 762                filter_chains.extend(
 763                    [
 764                        f"[{source}pts]split={n_splits}{suffixes}",
 765                    ]
 766                )
 767
 768        # special case, only one metric:
 769        if n_splits == 1:
 770            filter_chains.extend(
 771                self._get_metric_filter_chains(metrics[0], "distpts", "refpts")
 772            )
 773        # all other cases:
 774        else:
 775            for n, metric_name in zip(range(1, n_splits + 1), metrics):
 776                filter_chains.extend(
 777                    self._get_metric_filter_chains(metric_name, f"dist{n}", f"ref{n}")
 778                )
 779
 780        # drop results of earlier calls to avoid accumulate metrics
 781        # rebind (instead of clearing) so previously returned results stay intact
 782        for metric_name in metrics:
 783            self.data[metric_name] = []
 784        self.global_stats = {}
 785
 786        try:
 787            output = self._run_ffmpeg_command(filter_chains, desc=", ".join(metrics))
 788            if self.dry_run:
 789                return {}
 790            self._read_temp_files(metrics)
 791            if output:
 792                self._read_ffmpeg_output(output, metrics)
 793            else:
 794                raise FfmpegQualityMetricsError("ffmpeg output is empty!")
 795        except RuntimeError as e:
 796            if "could not initialize feature extractor" in str(e):
 797                raise FfmpegQualityMetricsError(
 798                    "Your ffmpeg build is linked against a libvmaf version that does not support "
 799                    "one of the features required by the chosen VMAF model. "
 800                    "VMAF v1 models (vmaf_v1.0.16 and newer) require libvmaf 3.2.1 or newer. "
 801                    f"Original error:\n{e}"
 802                ) from e
 803            raise
 804        finally:
 805            self._cleanup_temp_files()
 806
 807        # return only those data entries containing values
 808        return {k: v for k, v in self.data.items() if v}
 809
 810    def _get_libvmaf_filter_opts(self) -> str:
 811        """
 812        Returns:
 813
 814            str: A string to use for VMAF in ffmpeg filter chain
 815        """
 816        # we only have one model, and its path parameter is not optional
 817        all_model_params: Dict[str, str] = {
 818            "path": win_vmaf_model_path_check(self.vmaf_model_path)
 819        }
 820
 821        # add further model parameters
 822        for model_param in self.vmaf_options["model_params"]:
 823            key, value = model_param.split("=")
 824            all_model_params[key] = value
 825
 826        all_model_params_str = "\\:".join(
 827            f"{k}={v}" for k, v in all_model_params.items()
 828        )
 829
 830        vmaf_opts: Dict[str, str] = {
 831            "model": all_model_params_str,
 832            "log_path": win_path_check(self.temp_files["libvmaf"]),
 833            "log_fmt": "json",
 834            "n_threads": str(self.vmaf_options["n_threads"]),
 835            "n_subsample": str(self.vmaf_options["n_subsample"]),
 836            # Terminate at the shorter of the two aligned streams. libvmaf is a
 837            # framesync filter whose default (shortest=0, repeatlast=1) repeats
 838            # the last frame of the shorter stream until the longer one ends.
 839            # After dist_delay trims the leading frames to align the starts, the
 840            # reference is typically still longer than the distorted clip (e.g. a
 841            # full-length source vs a short recording); without shortest=1 those
 842            # trailing reference frames are compared against a frozen distorted
 843            # frame, cratering the pooled score. Compare only the overlap.
 844            "shortest": "1",
 845            "repeatlast": "0",
 846        }
 847
 848        if self.vmaf_options["features"]:
 849            features = []
 850            for feature in self.vmaf_options["features"]:
 851                if not feature.startswith("name"):
 852                    feature = f"name={feature}"
 853                features.append(feature.replace(":", "\\:"))
 854            vmaf_opts["feature"] = "|".join(features)
 855
 856        vmaf_opts_string = ":".join(
 857            f"{k}={v}" for k, v in vmaf_opts.items() if v is not None
 858        )
 859
 860        return vmaf_opts_string
 861
 862    def _check_libvmaf_availability(self) -> None:
 863        if "libvmaf" not in self.available_filters:
 864            raise FfmpegQualityMetricsError(
 865                "Your ffmpeg build does not have support for VMAF. Make sure you download or build a version compiled with --enable-libvmaf!"
 866            )
 867
 868    def _set_vmaf_model_path(self, model_path: Union[str, None] = None) -> None:
 869        """
 870        Logic to set the model path depending on the default or the user-supplied string
 871        """
 872        if model_path is None:
 873            self.vmaf_model_path = FfmpegQualityMetrics.get_default_vmaf_model_path()
 874        else:
 875            self.vmaf_model_path = str(model_path)
 876
 877        supplied_models = FfmpegQualityMetrics.get_supplied_vmaf_models()
 878
 879        if not os.path.isfile(self.vmaf_model_path):
 880            # check if this is one of the supplied ones? e.g. user passed only a filename
 881            if self.vmaf_model_path in supplied_models:
 882                self.vmaf_model_path = os.path.join(
 883                    FfmpegQualityMetrics.DEFAULT_VMAF_MODEL_DIRECTORY,
 884                    self.vmaf_model_path,
 885                )
 886            else:
 887                raise FfmpegQualityMetricsError(
 888                    f"Could not find model at {self.vmaf_model_path}. Please set --model-path to a valid VMAF .json model file."
 889                )
 890
 891    def _read_vmaf_temp_file(self) -> None:
 892        """
 893        Read the VMAF temp file and append the data to the data dict.
 894        """
 895        with open(self.temp_files["libvmaf"], "r") as in_vmaf:
 896            vmaf_log = json.load(in_vmaf)
 897            logger.debug(f"VMAF log: {json.dumps(vmaf_log, indent=4)}")
 898            for frame_data in vmaf_log["frames"]:
 899                # append frame number, increase +1
 900                frame_data["metrics"]["n"] = int(frame_data["frameNum"]) + 1
 901                self.data["vmaf"].append(frame_data["metrics"])
 902
 903    def _read_ffmpeg_output(
 904        self, ffmpeg_output: str, metrics: List[MetricName]
 905    ) -> None:
 906        """
 907        Read the metric values from ffmpeg's stderr, for those that don't output
 908        to a file.
 909        """
 910        if self.dry_run:
 911            return
 912        if "vif" in metrics:
 913            self._parse_ffmpeg_metadata_output(ffmpeg_output, "vif")
 914        if "msad" in metrics:
 915            self._parse_ffmpeg_metadata_output(ffmpeg_output, "msad")
 916
 917    def _parse_ffmpeg_metadata_output(
 918        self, ffmpeg_output: str, metric_name: Literal["vif", "msad"]
 919    ) -> None:
 920        """
 921        Parse the filter output written to ffmpeg's metadata output
 922
 923        Args:
 924            ffmpeg_output (str): The output of ffmpeg's stderr
 925            metric_name (Literal["vif", "msad"]): The name of the metric to parse
 926        """
 927        # Example for VIF:
 928        #
 929        # [Parsed_metadata_4 @ 0x7f995cd08640] frame:1    pts:1       pts_time:0.0401x
 930        # [Parsed_metadata_4 @ 0x7f995cd08640] lavfi.vif.scale.0=0.263582
 931        # [Parsed_metadata_4 @ 0x7f995cd08640] lavfi.vif.scale.1=0.560129
 932        # [Parsed_metadata_4 @ 0x7f995cd08640] lavfi.vif.scale.2=0.626596
 933        # [Parsed_metadata_4 @ 0x7f995cd08640] lavfi.vif.scale.3=0.682183
 934        #
 935        # Example for MSAD:
 936        #
 937        # [Parsed_metadata_6 @ 0x10ad04ea0] lavfi.msad.msad.Y=0.029998
 938        # [Parsed_metadata_6 @ 0x10ad04ea0] lavfi.msad.msad.U=0.019501
 939        # [Parsed_metadata_6 @ 0x10ad04ea0] lavfi.msad.msad.V=0.026455
 940        # [Parsed_metadata_6 @ 0x10ad04ea0] lavfi.msad.msad_avg=0.025318
 941
 942        # Each metadata filter instance (e.g. "[Parsed_metadata_4") prints its own
 943        # "frame:" lines, and the output of several instances is interleaved.
 944        # Collect the frames per instance, then keep only the instances that
 945        # printed values for this metric.
 946        frames_by_instance: Dict[str, List[Dict[str, float]]] = {}
 947        metric_instances: List[str] = []
 948        metric_prefix = f"lavfi.{metric_name}."
 949
 950        for line in ffmpeg_output.split("\n"):
 951            line = line.strip()
 952            if not line.startswith("[Parsed_metadata"):
 953                continue
 954
 955            fields = line.split(" ")
 956            if len(fields) < 4:
 957                continue
 958            instance, entry = fields[0], fields[3]
 959
 960            # a new frame appears
 961            if entry.startswith("frame:"):
 962                # the metadata filter counts from 0, all other metrics count from 1
 963                frame_data: Dict[str, float] = {"n": int(entry.split(":")[1]) + 1}
 964                frames_by_instance.setdefault(instance, []).append(frame_data)
 965                continue
 966
 967            # no frame was set, or no info for this metric
 968            if instance not in frames_by_instance or not entry.startswith(
 969                metric_prefix
 970            ):
 971                continue
 972
 973            if instance not in metric_instances:
 974                metric_instances.append(instance)
 975
 976            key, value = entry.split("=")
 977            key = key.replace(metric_prefix, "").replace(".", "_").lower()
 978            frames_by_instance[instance][-1][key] = round(float(value), 3)
 979
 980        for instance in metric_instances:
 981            self.data[metric_name].extend(frames_by_instance[instance])
 982
 983    def _read_temp_files(self, metrics: List[MetricName]) -> None:
 984        """
 985        Read the data from multiple temp files
 986        """
 987        if self.dry_run:
 988            return
 989        if "vmaf" in metrics:
 990            self._read_vmaf_temp_file()
 991        if "ssim" in metrics:
 992            self._read_ssim_temp_file()
 993        if "psnr" in metrics:
 994            self._read_psnr_temp_file()
 995
 996    def _run_ffmpeg_command(
 997        self, filter_chains: List[str], desc: str = ""
 998    ) -> Union[str, None]:
 999        """
1000        Run the ffmpeg command to get the quality metrics.
1001        The filter chains must be specified manually.
1002        'desc' can be a human readable description for the progress bar.
1003
1004        Returns:
1005            Union[str, None]: The output of ffmpeg's stderr
1006        """
1007        if not self.framerate:
1008            ref_framerate, dist_framerate = self._get_framerates()
1009        else:
1010            ref_framerate = self.framerate
1011            dist_framerate = self.framerate
1012
1013        # Parse start_offset
1014        start_offset_timestamp = self._parse_start_offset(ref_framerate)
1015
1016        cmd = [
1017            self.ffmpeg_path,
1018            "-nostdin",
1019            "-nostats",
1020            "-y",
1021            "-threads",
1022            str(self.threads),
1023        ]
1024
1025        # Add -ss before reference input if start_offset is specified
1026        if start_offset_timestamp is not None:
1027            cmd.extend(["-ss", start_offset_timestamp])
1028
1029        # Add -r before -i only if no start_offset (to avoid seeking issues)
1030        # When seeking is used, -r before -i can interfere with frame-accurate seeking
1031        if start_offset_timestamp is None:
1032            cmd.extend(["-r", str(ref_framerate)])
1033
1034        # Note: dist_delay is applied via a trim filter in the filter graph (see
1035        # calculate), not via -itsoffset. A PTS shift here would be cancelled by
1036        # the setpts=PTS-STARTPTS reset and would not align the streams.
1037        cmd.extend(["-i", self.ref])
1038
1039        # Add -ss before distorted input if start_offset is specified
1040        if start_offset_timestamp is not None:
1041            cmd.extend(["-ss", start_offset_timestamp])
1042
1043        # Add -r before -i only if no start_offset
1044        if start_offset_timestamp is None:
1045            cmd.extend(["-r", str(dist_framerate)])
1046
1047        cmd.extend(
1048            [
1049                "-i",
1050                self.dist,
1051                "-filter_complex",
1052                ";".join(filter_chains),
1053                "-an",
1054                "-f",
1055                "null",
1056                NUL,
1057            ]
1058        )
1059
1060        if self.dry_run:
1061            print(quoted_cmd(cmd))
1062            return ""
1063
1064        if self.progress:
1065            logger.debug(quoted_cmd(cmd))
1066            with FfmpegProgress(cmd, self.dry_run) as ff:
1067                with tqdm(total=100, position=1, desc=desc) as pbar:
1068                    for progress in ff.run_command_with_progress():
1069                        pbar.update(progress - pbar.n)
1070                return ff.stderr
1071        else:
1072            _, stderr = run_command(cmd, dry_run=self.dry_run)
1073            return stderr
1074
1075    def _cleanup_temp_files(self) -> None:
1076        """
1077        Remove the temporary files
1078        """
1079        for temp_file in self.temp_files.values():
1080            if os.path.isfile(temp_file):
1081                if self.keep_tmp_files:
1082                    logger.debug(f"Keeping temp file {temp_file}")
1083                else:
1084                    os.remove(temp_file)
1085
1086    def _read_psnr_temp_file(self) -> None:
1087        """
1088        Parse the PSNR generated logfile
1089        """
1090        with open(self.temp_files["psnr"], "r") as in_psnr:
1091            # n:1 mse_avg:529.52 mse_y:887.00 mse_u:233.33 mse_v:468.25 psnr_avg:20.89 psnr_y:18.65 psnr_u:24.45 psnr_v:21.43
1092            lines = in_psnr.readlines()
1093            for line in lines:
1094                line = line.strip()
1095                fields = line.split(" ")
1096                frame_data = {}
1097                for field in fields:
1098                    k, v = field.split(":")
1099                    frame_data[k] = round(float(v), 3) if k != "n" else int(v)
1100                self.data["psnr"].append(frame_data)
1101
1102    def _read_ssim_temp_file(self) -> None:
1103        """
1104        Parse the SSIM generated logfile
1105        """
1106        with open(self.temp_files["ssim"], "r") as in_ssim:
1107            # n:1 Y:0.937213 U:0.961733 V:0.945788 All:0.948245 (12.860441)\n
1108            lines = in_ssim.readlines()
1109            for line in lines:
1110                line = line.strip().split(" (")[0]  # remove excess
1111                fields = line.split(" ")
1112                frame_data = {}
1113                for field in fields:
1114                    k, v = field.split(":")
1115                    if k != "n":
1116                        # make psnr and ssim keys the same
1117                        k = "ssim_" + k.lower()
1118                        k = k.replace("all", "avg")
1119                    frame_data[k] = round(float(v), 3) if k != "n" else int(v)
1120                self.data["ssim"].append(frame_data)
1121
1122    @staticmethod
1123    def get_brewed_vmaf_model_path() -> Union[str, None]:
1124        """
1125        Hack to get path for VMAF model from Homebrew or Linuxbrew.
1126        This works for libvmaf 2.x
1127
1128        Returns:
1129            str or None: the path or None if not found
1130        """
1131        stdout, _ = run_command(["brew", "--prefix", "libvmaf"])
1132        cellar_path = stdout.strip()
1133
1134        model_path = os.path.join(cellar_path, "share", "libvmaf", "model")
1135
1136        if not os.path.isdir(model_path):
1137            logger.warning(
1138                f"{model_path} does not exist. Are you sure you have installed the most recent version of libvmaf with Homebrew?"
1139            )
1140            return None
1141
1142        return model_path
1143
1144    @staticmethod
1145    def get_default_vmaf_model_path() -> str:
1146        """
1147        Return the default model path depending on whether the user is running Homebrew
1148        or has a static build.
1149
1150        Returns:
1151            str: the path
1152        """
1153        if has_brew() and ffmpeg_is_from_brew():
1154            # If the user installed ffmpeg using homebrew
1155            model_path = FfmpegQualityMetrics.get_brewed_vmaf_model_path()
1156            if model_path is not None:
1157                return os.path.join(
1158                    model_path,
1159                    "vmaf_v0.6.1.json",
1160                )
1161
1162        share_path = os.path.join("/usr", "local", "share", "model")
1163        if os.path.isdir(share_path):
1164            return os.path.join(share_path, "vmaf_v0.6.1.json")
1165        else:
1166            # return the bundled file as a fallback
1167            return os.path.join(
1168                FfmpegQualityMetrics.DEFAULT_VMAF_MODEL_DIRECTORY, "vmaf_v0.6.1.json"
1169            )
1170
1171    @staticmethod
1172    def get_supplied_vmaf_models() -> List[str]:
1173        """
1174        Return a list of VMAF models supplied with the software.
1175
1176        Returns:
1177            List[str]: A list of VMAF model names
1178        """
1179        return sorted(
1180            f
1181            for f in os.listdir(FfmpegQualityMetrics.DEFAULT_VMAF_MODEL_DIRECTORY)
1182            if f.endswith(".json")
1183        )
1184
1185    def get_global_stats(self) -> GlobalStats:
1186        """
1187        Return a dictionary for each calculated metric, with different statstics
1188
1189        Returns:
1190            dict: A dictionary with stats, each key being a metric name and each value being a dictionary with the stats for every submetric. The stats are: 'average', 'median', 'stdev', 'min', 'max'.
1191        """
1192        for metric_name in self.data:
1193            logger.debug(f"Aggregating stats for {metric_name}")
1194            metric_data = self.data[metric_name]
1195            if len(metric_data) == 0:
1196                continue
1197            submetric_keys = [k for k in metric_data[0].keys() if k != "n"]
1198
1199            stats: Dict[str, GlobalStatsData] = {}
1200            for submetric_key in submetric_keys:
1201                values = [float(frame[submetric_key]) for frame in metric_data]
1202                # Filter out non-finite values (inf, -inf, nan) for robust statistics
1203                finite = [v for v in values if math.isfinite(v)]
1204                # Fallback to [0.0] if all values are non-finite to prevent crashes
1205                all_values = finite if finite else [0.0]
1206
1207                stats[submetric_key] = {
1208                    "average": round(float(mean(all_values)), 3),
1209                    "median": round(float(median(all_values)), 3),
1210                    "stdev": round(
1211                        float(pstdev(finite)) if len(finite) > 1 else 0.0, 3
1212                    ),
1213                    "min": round(min(all_values), 3),
1214                    "max": round(max(all_values), 3),
1215                }
1216            self.global_stats[metric_name] = stats
1217
1218        return self.global_stats
1219
1220    def get_summary(self) -> str:
1221        """
1222        Return a short, human-readable table of the global stats (average, min, max)
1223        for the main submetric of each calculated metric.
1224
1225        The main submetrics are defined in `SUMMARY_SUBMETRICS`. If a metric has none of them,
1226        all of its submetrics are listed.
1227
1228        Returns:
1229            str: The summary table, or an empty string if no data was calculated
1230        """
1231        global_stats = self.get_global_stats()
1232        if not global_stats:
1233            return ""
1234
1235        rows: List[Tuple[str, GlobalStatsData]] = []
1236        for metric_name, metric_stats in global_stats.items():
1237            main_keys = [
1238                k
1239                for k in self.SUMMARY_SUBMETRICS.get(metric_name, [])
1240                if k in metric_stats
1241            ]
1242            for key in main_keys or list(metric_stats.keys()):
1243                label = key if key.startswith(metric_name) else f"{metric_name}_{key}"
1244                rows.append((label, metric_stats[key]))
1245
1246        n_frames = max(len(frames) for frames in self.data.values())
1247        label_width = max(len("metric"), *(len(label) for label, _ in rows))
1248
1249        lines = [
1250            f"Summary over {n_frames} frame{'s' if n_frames != 1 else ''}:",
1251            f"  {'metric':<{label_width}} {'average':>10} {'min':>10} {'max':>10}",
1252        ]
1253        for label, stats in rows:
1254            lines.append(
1255                f"  {label:<{label_width}} {stats['average']:>10.3f}"
1256                f" {stats['min']:>10.3f} {stats['max']:>10.3f}"
1257            )
1258        return "\n".join(lines)
1259
1260    def get_results_csv(self) -> str:
1261        """
1262        Return a CSV string with the data
1263
1264        Returns:
1265            str: The CSV string
1266        """
1267        # Check if we have any data
1268        has_data = any(metric_data for metric_data in self.data.values())
1269        if not has_data:
1270            raise FfmpegQualityMetricsError("No data calculated!")
1271
1272        # Collect all frames and merge data by frame number
1273        frames_data: Dict[int, Dict[str, Union[str, float, int]]] = {}
1274
1275        # Process each metric's data
1276        for metric_data in self.data.values():
1277            if not metric_data:
1278                continue
1279
1280            for frame_info in cast(SingleMetricData, metric_data):
1281                frame_num = int(frame_info["n"])
1282                if frame_num not in frames_data:
1283                    frames_data[frame_num] = {"n": frame_num}
1284
1285                # Add all metric properties for this frame
1286                for key, value in frame_info.items():
1287                    if key != "n":  # Skip frame number as it's already added
1288                        frames_data[frame_num][key] = value
1289
1290        if not frames_data:
1291            raise FfmpegQualityMetricsError("No frame data found!")
1292
1293        # Sort frames by frame number
1294        sorted_frames = sorted(frames_data.keys())
1295
1296        # Collect all unique column names (excluding 'n' which we'll put first)
1297        all_columns: set[str] = set()
1298        for frame_data in frames_data.values():
1299            all_columns.update(frame_data.keys())
1300        all_columns.discard("n")
1301
1302        # Create column order: n first, then sorted metric columns, then input files
1303        columns = ["n"] + sorted(all_columns) + ["input_file_dist", "input_file_ref"]
1304
1305        # Generate CSV using StringIO and csv module
1306        output = StringIO()
1307        writer = csv.writer(output)
1308
1309        # Write header
1310        writer.writerow(columns)
1311
1312        # Write data rows
1313        for frame_num in sorted_frames:
1314            frame_data = frames_data[frame_num]
1315            row = []
1316            for col in columns:
1317                if col == "input_file_dist":
1318                    row.append(self.dist)
1319                elif col == "input_file_ref":
1320                    row.append(self.ref)
1321                else:
1322                    # Use the frame data value or empty string if not present
1323                    row.append(str(frame_data.get(col, "")))
1324            writer.writerow(row)
1325
1326        return output.getvalue()
1327
1328    def get_results_json(self) -> str:
1329        """
1330        Return the results as JSON string
1331
1332        Returns:
1333            str: The JSON string
1334        """
1335        ret: Dict = {}
1336        for key in self.data:
1337            metric_data = self.data[key]
1338            if len(metric_data) == 0:
1339                continue
1340            ret[key] = metric_data
1341        ret["global"] = self.get_global_stats()
1342        ret["input_file_dist"] = self.dist
1343        ret["input_file_ref"] = self.ref
1344
1345        return json.dumps(ret, indent=4)

A class to calculate quality metrics with FFmpeg

FfmpegQualityMetrics( ref: str, dist: str, scaling_algorithm: str = 'bicubic', framerate: float | None = None, dist_delay: float = 0, dry_run: bool | None = False, verbose: bool | None = False, threads: int = 0, progress: bool | None = False, keep_tmp_files: bool | None = False, tmp_dir: str | None = None, num_frames: int | None = None, start_offset: str | None = None, ffmpeg_path: str = 'ffmpeg')
168    def __init__(
169        self,
170        ref: str,
171        dist: str,
172        scaling_algorithm: str = DEFAULT_SCALER,
173        framerate: Union[float, None] = None,
174        dist_delay: float = 0,
175        dry_run: Union[bool, None] = False,
176        verbose: Union[bool, None] = False,
177        threads: int = DEFAULT_THREADS,
178        progress: Union[bool, None] = False,
179        keep_tmp_files: Union[bool, None] = False,
180        tmp_dir: Union[str, None] = None,
181        num_frames: Union[int, None] = None,
182        start_offset: Union[str, None] = None,
183        ffmpeg_path: str = "ffmpeg",
184    ):
185        """Instantiate a new FfmpegQualityMetrics
186
187        Args:
188            ref (str): reference file
189            dist (str): distorted file
190            scaling_algorithm (str, optional): A scaling algorithm. Must be one of the following: ["fast_bilinear", "bilinear", "bicubic", "experimental", "neighbor", "area", "bicublin", "gauss", "sinc", "lanczos", "spline"]. Defaults to "bicubic"
191            framerate (float, optional): Force a frame rate. Defaults to None.
192            dist_delay (float): Temporally align the distorted file against the reference by this many seconds, by trimming the leading unmatched frames of one stream before comparison. A positive value means the distorted stream starts this many seconds after the reference (the reference's leading frames are trimmed); a negative value means the distorted stream leads (its leading frames are trimmed). Defaults to 0.
193            dry_run (bool, optional): Don't run anything, just print commands. Defaults to False.
194            verbose (bool, optional): Show more output. Defaults to False.
195            threads (int, optional): Number of ffmpeg threads. Defaults to 0 (auto).
196            progress (bool, optional): Show a progress bar. Defaults to False.
197            keep_tmp_files (bool, optional): Keep temporary files for debugging purposes. Defaults to False.
198            tmp_dir (str, optional): Directory to store temporary files. Will use system default if not specified. Defaults to None.
199            num_frames (int, optional): Number of frames to analyze from the input files. Defaults to None (all frames).
200            start_offset (str, optional): Seek to this position before analyzing. Accepts timestamp (e.g., '00:00:10' or '10.5') or frame number with 'f:' prefix (e.g., 'f:100'). Defaults to None.
201            ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
202
203        Raises:
204            FfmpegQualityMetricsError: A generic error
205        """
206        self.ref = str(ref)
207        self.dist = str(dist)
208        self.scaling_algorithm = str(scaling_algorithm)
209        self.framerate = float(framerate) if framerate is not None else None
210        self.dist_delay = float(dist_delay)
211        self.dry_run = bool(dry_run)
212        self.verbose = bool(verbose)
213        self.threads = int(threads)
214        self.progress = bool(progress)
215        self.keep_tmp_files = bool(keep_tmp_files)
216        self.tmp_dir = str(tmp_dir) if tmp_dir is not None else tempfile.gettempdir()
217        self.num_frames = int(num_frames) if num_frames is not None else None
218        self.start_offset = str(start_offset) if start_offset is not None else None
219        self.ffmpeg_path = ffmpeg_path
220
221        if not os.path.isfile(self.ref):
222            raise FfmpegQualityMetricsError(f"Reference file not found: {self.ref}")
223        if not os.path.isfile(self.dist):
224            raise FfmpegQualityMetricsError(f"Distorted file not found: {self.dist}")
225
226        if self.ref == self.dist:
227            logger.warning(
228                "Reference and distorted files are the same! This may lead to unexpected results or numerical issues."
229            )
230
231        if ref.endswith(".yuv") or dist.endswith(".yuv"):
232            raise FfmpegQualityMetricsError(
233                "YUV files are not supported, please convert to a format that ffmpeg can read natively, such as Y4M or FFV1."
234            )
235
236        self.data: MetricData = {
237            "vmaf": [],
238            "psnr": [],
239            "ssim": [],
240            "vif": [],
241            "msad": [],
242        }
243
244        self.available_filters: List[str] = []
245
246        self.global_stats: GlobalStats = {}
247
248        self.input_info: Union[Tuple[InputInfo, InputInfo], None] = None
249        """Properties of the reference and distorted file, set by `check_inputs()`."""
250        self._inputs_checked = False
251
252        if not os.path.isdir(self.tmp_dir):
253            logger.debug(f"Creating temporary directory: {self.tmp_dir}")
254            os.makedirs(self.tmp_dir)
255        self.temp_files: Dict[FilterName, str] = {}
256
257        for filter_name in self.POSSIBLE_FILTERS:
258            suffix = "txt" if filter_name != "libvmaf" else "json"
259
260            self.temp_files[filter_name] = os.path.join(
261                self.tmp_dir,
262                f"ffmpeg_quality_metrics_{filter_name}_{os.path.basename(self.ref)}_{os.path.basename(self.dist)}.{suffix}",
263            )
264            logger.debug(
265                f"Writing temporary {filter_name.upper()} information to: {self.temp_files[filter_name]}"
266            )
267
268        if scaling_algorithm not in self.ALLOWED_SCALERS:
269            raise FfmpegQualityMetricsError(
270                f"Allowed scaling algorithms: {self.ALLOWED_SCALERS}"
271            )
272
273        self._check_available_filters()

Instantiate a new FfmpegQualityMetrics

Arguments:
  • ref (str): reference file
  • dist (str): distorted file
  • scaling_algorithm (str, optional): A scaling algorithm. Must be one of the following: ["fast_bilinear", "bilinear", "bicubic", "experimental", "neighbor", "area", "bicublin", "gauss", "sinc", "lanczos", "spline"]. Defaults to "bicubic"
  • framerate (float, optional): Force a frame rate. Defaults to None.
  • dist_delay (float): Temporally align the distorted file against the reference by this many seconds, by trimming the leading unmatched frames of one stream before comparison. A positive value means the distorted stream starts this many seconds after the reference (the reference's leading frames are trimmed); a negative value means the distorted stream leads (its leading frames are trimmed). Defaults to 0.
  • dry_run (bool, optional): Don't run anything, just print commands. Defaults to False.
  • verbose (bool, optional): Show more output. Defaults to False.
  • threads (int, optional): Number of ffmpeg threads. Defaults to 0 (auto).
  • progress (bool, optional): Show a progress bar. Defaults to False.
  • keep_tmp_files (bool, optional): Keep temporary files for debugging purposes. Defaults to False.
  • tmp_dir (str, optional): Directory to store temporary files. Will use system default if not specified. Defaults to None.
  • num_frames (int, optional): Number of frames to analyze from the input files. Defaults to None (all frames).
  • start_offset (str, optional): Seek to this position before analyzing. Accepts timestamp (e.g., '00:00:10' or '10.5') or frame number with 'f:' prefix (e.g., 'f:100'). Defaults to None.
  • ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
Raises:
  • FfmpegQualityMetricsError: A generic error
ALLOWED_SCALERS = ['fast_bilinear', 'bilinear', 'bicubic', 'experimental', 'neighbor', 'area', 'bicublin', 'gauss', 'sinc', 'lanczos', 'spline']
DEFAULT_SCALER = 'bicubic'
DEFAULT_THREADS = 0
DEFAULT_VMAF_THREADS = 0
DEFAULT_VMAF_SUBSAMPLE = 1
DEFAULT_VMAF_MODEL_DIRECTORY = '/home/werner/Documents/Projects/slhck/ffmpeg-quality-metrics/src/ffmpeg_quality_metrics/vmaf_models'
DEFAULT_VMAF_OPTIONS: VmafOptions = {'model_path': None, 'model_params': [], 'n_threads': 0, 'n_subsample': 1, 'features': [], 'ten_bit': False}
POSSIBLE_FILTERS: List[Literal['psnr', 'ssim', 'libvmaf', 'vif', 'msad']] = ['libvmaf', 'psnr', 'ssim', 'vif', 'msad']
METRIC_TO_FILTER_MAP: Dict[Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad'], Literal['psnr', 'ssim', 'libvmaf', 'vif', 'msad']] = {'vmaf': 'libvmaf', 'psnr': 'psnr', 'ssim': 'ssim', 'vif': 'vif', 'msad': 'msad'}
SUMMARY_SUBMETRICS: Dict[Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad'], List[str]] = {'psnr': ['psnr_avg'], 'ssim': ['ssim_avg'], 'vmaf': ['vmaf'], 'vif': ['scale_0', 'scale_1', 'scale_2', 'scale_3'], 'msad': ['msad_avg']}

Main submetrics of each metric, shown in the summary from get_summary().

ref
dist
scaling_algorithm
framerate
dist_delay
dry_run
verbose
threads
progress
keep_tmp_files
tmp_dir
num_frames
start_offset
ffmpeg_path
data: Dict[Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad'], List[Dict[str, float]]]
available_filters: List[str]
global_stats: Dict[Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad'], Dict[str, Dict[str, float]]]
input_info: Tuple[InputInfo, InputInfo] | None

Properties of the reference and distorted file, set by check_inputs().

temp_files: Dict[Literal['psnr', 'ssim', 'libvmaf', 'vif', 'msad'], str]
@staticmethod
def get_framerate(input_file: str, ffmpeg_path: str = 'ffmpeg') -> float:
303    @staticmethod
304    def get_framerate(input_file: str, ffmpeg_path: str = "ffmpeg") -> float:
305        """Parse the FPS from the input file.
306
307        First parse it from the ffmpeg banner ("<n> fps"). Some container/codec
308        combinations (e.g. FFV1 in a .nut) do not print an fps token in the
309        banner (only "tbr"/"tbn"), so fall back to querying ffprobe for the
310        exact rational frame rate. ffprobe ships with ffmpeg, so this adds no
311        new dependency.
312
313        Args:
314            input_file (str): Input file path
315            ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
316
317        Raises:
318            FfmpegQualityMetricsError: A generic error
319
320        Returns:
321            float: The FPS parsed
322        """
323        cmd = [ffmpeg_path, "-nostdin", "-y", "-i", input_file]
324
325        output = run_command(cmd, allow_error=True)
326        pattern = re.compile(r"(\d+(\.\d+)?) fps")
327        try:
328            if pattern_ret := pattern.search(str(output)):
329                match = pattern_ret.groups()[0]
330                return float(match)
331        except Exception:
332            pass
333
334        # Fallback: the ffmpeg banner did not contain an "<n> fps" token (this
335        # happens for some container/codec combinations, e.g. FFV1 in .nut,
336        # which only report "tbr"/"tbn"). Query ffprobe for the exact rational
337        # frame rate instead of failing. Prefer r_frame_rate, then
338        # avg_frame_rate; skip missing/"0/0" values.
339        ffprobe_path = get_ffprobe_path(ffmpeg_path)
340        for field in ("r_frame_rate", "avg_frame_rate"):
341            try:
342                stdout, _ = run_command(
343                    [
344                        ffprobe_path,
345                        "-loglevel",
346                        "error",
347                        "-select_streams",
348                        "v:0",
349                        "-show_entries",
350                        f"stream={field}",
351                        "-of",
352                        "csv=p=0",
353                        input_file,
354                    ],
355                    allow_error=True,
356                )
357            except Exception:
358                continue
359            value = stdout.strip().splitlines()[0].strip() if stdout.strip() else ""
360            if not value or value == "0/0":
361                continue
362            try:
363                frac = Fraction(value)
364            except (ValueError, ZeroDivisionError):
365                continue
366            if frac > 0:
367                return float(frac)
368
369        raise FfmpegQualityMetricsError(f"could not parse FPS from file {input_file}!")

Parse the FPS from the input file.

First parse it from the ffmpeg banner (" fps"). Some container/codec combinations (e.g. FFV1 in a .nut) do not print an fps token in the banner (only "tbr"/"tbn"), so fall back to querying ffprobe for the exact rational frame rate. ffprobe ships with ffmpeg, so this adds no new dependency.

Arguments:
  • input_file (str): Input file path
  • ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
Raises:
  • FfmpegQualityMetricsError: A generic error
Returns:

float: The FPS parsed

@staticmethod
def probe_input( input_file: str, ffmpeg_path: str = 'ffmpeg') -> InputInfo:
390    @staticmethod
391    def probe_input(input_file: str, ffmpeg_path: str = "ffmpeg") -> InputInfo:
392        """Read the properties of the first video stream of a file with ffprobe.
393
394        ffprobe is expected next to the ffmpeg executable, or in the PATH.
395
396        Args:
397            input_file (str): Input file path
398            ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
399
400        Raises:
401            FfmpegQualityMetricsError: If the file does not exist, cannot be read, or has no video stream
402            FileNotFoundError: If ffprobe cannot be found
403
404        Returns:
405            InputInfo: The video stream properties
406        """
407        if not os.path.isfile(input_file):
408            raise FfmpegQualityMetricsError(f"File not found: {input_file}")
409
410        cmd = [
411            get_ffprobe_path(ffmpeg_path),
412            "-loglevel",
413            "error",
414            "-select_streams",
415            "v:0",
416            "-show_entries",
417            "stream=width,height,r_frame_rate,avg_frame_rate,duration,nb_frames"
418            ":format=duration",
419            "-of",
420            "json",
421            input_file,
422        ]
423        try:
424            stdout, _ = run_command(cmd)
425            probe_data = json.loads(stdout)
426        except (RuntimeError, ValueError) as e:
427            # the last line of ffprobe's error output names the problem
428            detail = str(e).strip().splitlines()[-1] if str(e).strip() else ""
429            raise FfmpegQualityMetricsError(
430                f"Could not read file {input_file} with ffprobe: {detail}"
431            ) from e
432
433        streams = probe_data.get("streams") or []
434        if not streams:
435            raise FfmpegQualityMetricsError(f"No video stream found in {input_file}")
436        stream = streams[0]
437
438        framerate = None
439        for field in ("r_frame_rate", "avg_frame_rate"):
440            try:
441                frac = Fraction(stream.get(field, ""))
442            except (ValueError, ZeroDivisionError):
443                continue
444            if frac > 0:
445                framerate = float(frac)
446                break
447
448        duration = None
449        for value in (
450            stream.get("duration"),
451            probe_data.get("format", {}).get("duration"),
452        ):
453            try:
454                duration = float(value)
455                break
456            except (TypeError, ValueError):
457                continue
458
459        try:
460            nb_frames = int(stream["nb_frames"])
461        except (KeyError, TypeError, ValueError):
462            nb_frames = None
463
464        return {
465            "path": input_file,
466            "width": int(stream.get("width", 0)),
467            "height": int(stream.get("height", 0)),
468            "framerate": framerate,
469            "duration": duration,
470            "nb_frames": nb_frames,
471        }

Read the properties of the first video stream of a file with ffprobe.

ffprobe is expected next to the ffmpeg executable, or in the PATH.

Arguments:
  • input_file (str): Input file path
  • ffmpeg_path (str, optional): Path to ffmpeg executable. Defaults to "ffmpeg".
Raises:
  • FfmpegQualityMetricsError: If the file does not exist, cannot be read, or has no video stream
  • FileNotFoundError: If ffprobe cannot be found
Returns:

InputInfo: The video stream properties

def check_inputs( self) -> Tuple[InputInfo, InputInfo] | None:
473    def check_inputs(self) -> Union[Tuple[InputInfo, InputInfo], None]:
474        """Probe both input files and log a warning for each property that differs.
475
476        Compares resolution, frame rate, and duration (or frame count). This is run
477        automatically before the first calculation.
478
479        Raises:
480            FfmpegQualityMetricsError: If an input cannot be read or has no video stream
481
482        Returns:
483            Tuple[InputInfo, InputInfo] or None: The properties of the reference and distorted file,
484                or None if ffprobe is not available
485        """
486        try:
487            ref_info = FfmpegQualityMetrics.probe_input(self.ref, self.ffmpeg_path)
488            dist_info = FfmpegQualityMetrics.probe_input(self.dist, self.ffmpeg_path)
489        except FileNotFoundError:
490            logger.warning(
491                f"ffprobe not found at {get_ffprobe_path(self.ffmpeg_path)}, skipping input checks"
492            )
493            return None
494
495        logger.debug(f"Reference input: {ref_info}")
496        logger.debug(f"Distorted input: {dist_info}")
497
498        self._check_resolution(ref_info, dist_info)
499        self._check_framerate(ref_info, dist_info)
500        self._check_duration(ref_info, dist_info)
501
502        self.input_info = (ref_info, dist_info)
503        return self.input_info

Probe both input files and log a warning for each property that differs.

Compares resolution, frame rate, and duration (or frame count). This is run automatically before the first calculation.

Raises:
  • FfmpegQualityMetricsError: If an input cannot be read or has no video stream
Returns:

Tuple[InputInfo, InputInfo] or None: The properties of the reference and distorted file, or None if ffprobe is not available

def calculate( self, metrics: List[Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad']] | None = None, vmaf_options: VmafOptions | None = None) -> Dict[Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad'], List[Dict[str, float]]]:
674    def calculate(
675        self,
676        metrics: Union[List[MetricName], None] = None,
677        vmaf_options: Union[VmafOptions, None] = None,
678    ) -> Dict[MetricName, SingleMetricData]:
679        """Calculate one or more metrics.
680
681        Args:
682            metrics (list, optional): A list of metrics to calculate.
683                Possible values are ["ssim", "psnr", "vmaf"].
684                Defaults to ["ssim", "psnr"].
685            vmaf_options (dict, optional): VMAF-specific options. Uses defaults if not specified.
686
687        Raises:
688            FfmpegQualityMetricsError: In case of an error
689            e: A generic error
690
691        Returns:
692            dict: A dictionary of per-frame info, with the key being the metric name and the value being a dict of frame numbers ('n') and metric values.
693        """
694        if metrics is None:
695            metrics = ["ssim", "psnr"]
696        if not metrics:
697            raise FfmpegQualityMetricsError("No metrics specified!")
698
699        # check available metrics
700        for metric_name in metrics:
701            filter_name = self.METRIC_TO_FILTER_MAP.get(metric_name, None)
702            if filter_name not in self.POSSIBLE_FILTERS:
703                raise FfmpegQualityMetricsError(f"No such metric '{metric_name}'")
704            if filter_name not in self.available_filters:
705                raise FfmpegQualityMetricsError(
706                    f"Your ffmpeg version does not have the filter '{filter_name}'"
707                )
708
709        # probe the inputs once, before running ffmpeg
710        if not self._inputs_checked:
711            self.check_inputs()
712            self._inputs_checked = True
713
714        # set VMAF options specifically
715        if "vmaf" in metrics:
716            self._check_libvmaf_availability()
717            self.vmaf_options = self.DEFAULT_VMAF_OPTIONS.copy()
718            # override with user-supplied options
719            if vmaf_options:
720                for key, value in vmaf_options.items():
721                    if value is not None:
722                        self.vmaf_options[key] = value  # type: ignore
723            self._set_vmaf_model_path(self.vmaf_options["model_path"])
724
725        # ffmpeg 7.1 or higher: scale2ref filter is deprecated
726        # input 0: ref, input 1: dist --> swapped for scale filter
727
728        # Apply select filter if num_frames is specified
729        select_filter = ""
730        if self.num_frames is not None:
731            select_filter = f"select='lt(n\\,{self.num_frames})',"
732
733        # Apply dist_delay by trimming the leading, unmatched portion of one
734        # stream so the two streams are temporally aligned before comparison.
735        # libvmaf (and the other metric filters) pair frames sequentially, so a
736        # PTS-only shift (e.g. -itsoffset) is cancelled by the setpts reset below
737        # and has no effect. Trimming happens at decode time - no re-encode.
738        # dist_delay > 0: distorted is delayed against the reference, so the
739        #   reference has extra leading frames -> trim the reference.
740        # dist_delay < 0: the distorted stream leads -> trim the distorted.
741        ref_trim = f"trim=start={self.dist_delay}," if self.dist_delay > 0 else ""
742        dist_trim = f"trim=start={abs(self.dist_delay)}," if self.dist_delay < 0 else ""
743
744        # Align both streams before the reference is split between the scale
745        # filter and the metric filter. Feeding the untrimmed reference directly
746        # to scale while separately trimming it for the metric makes ffmpeg
747        # buffer the entire skipped lead-in on large positive delays. Real
748        # captures can start tens of seconds into a source, which previously
749        # grew memory into gigabytes and could be OOM-killed.
750        filter_chains = [
751            f"[0]{ref_trim}{select_filter}settb=AVTB,setpts=PTS-STARTPTS[refaligned]",
752            "[refaligned]split=2[refscale][refpts]",
753            f"[1]{dist_trim}{select_filter}settb=AVTB,setpts=PTS-STARTPTS[distaligned]",
754            f"[distaligned][refscale]scale=rw:rh:flags={self.scaling_algorithm}[distpts]",
755        ]
756
757        # generate split filters depending on the number of models
758        n_splits = len(metrics)
759        if n_splits > 1:
760            for source in ["dist", "ref"]:
761                suffixes = "".join([f"[{source}{n}]" for n in range(1, n_splits + 1)])
762                filter_chains.extend(
763                    [
764                        f"[{source}pts]split={n_splits}{suffixes}",
765                    ]
766                )
767
768        # special case, only one metric:
769        if n_splits == 1:
770            filter_chains.extend(
771                self._get_metric_filter_chains(metrics[0], "distpts", "refpts")
772            )
773        # all other cases:
774        else:
775            for n, metric_name in zip(range(1, n_splits + 1), metrics):
776                filter_chains.extend(
777                    self._get_metric_filter_chains(metric_name, f"dist{n}", f"ref{n}")
778                )
779
780        # drop results of earlier calls to avoid accumulate metrics
781        # rebind (instead of clearing) so previously returned results stay intact
782        for metric_name in metrics:
783            self.data[metric_name] = []
784        self.global_stats = {}
785
786        try:
787            output = self._run_ffmpeg_command(filter_chains, desc=", ".join(metrics))
788            if self.dry_run:
789                return {}
790            self._read_temp_files(metrics)
791            if output:
792                self._read_ffmpeg_output(output, metrics)
793            else:
794                raise FfmpegQualityMetricsError("ffmpeg output is empty!")
795        except RuntimeError as e:
796            if "could not initialize feature extractor" in str(e):
797                raise FfmpegQualityMetricsError(
798                    "Your ffmpeg build is linked against a libvmaf version that does not support "
799                    "one of the features required by the chosen VMAF model. "
800                    "VMAF v1 models (vmaf_v1.0.16 and newer) require libvmaf 3.2.1 or newer. "
801                    f"Original error:\n{e}"
802                ) from e
803            raise
804        finally:
805            self._cleanup_temp_files()
806
807        # return only those data entries containing values
808        return {k: v for k, v in self.data.items() if v}

Calculate one or more metrics.

Arguments:
  • metrics (list, optional): A list of metrics to calculate. Possible values are ["ssim", "psnr", "vmaf"]. Defaults to ["ssim", "psnr"].
  • vmaf_options (dict, optional): VMAF-specific options. Uses defaults if not specified.
Raises:
  • FfmpegQualityMetricsError: In case of an error
  • e: A generic error
Returns:

dict: A dictionary of per-frame info, with the key being the metric name and the value being a dict of frame numbers ('n') and metric values.

@staticmethod
def get_brewed_vmaf_model_path() -> str | None:
1122    @staticmethod
1123    def get_brewed_vmaf_model_path() -> Union[str, None]:
1124        """
1125        Hack to get path for VMAF model from Homebrew or Linuxbrew.
1126        This works for libvmaf 2.x
1127
1128        Returns:
1129            str or None: the path or None if not found
1130        """
1131        stdout, _ = run_command(["brew", "--prefix", "libvmaf"])
1132        cellar_path = stdout.strip()
1133
1134        model_path = os.path.join(cellar_path, "share", "libvmaf", "model")
1135
1136        if not os.path.isdir(model_path):
1137            logger.warning(
1138                f"{model_path} does not exist. Are you sure you have installed the most recent version of libvmaf with Homebrew?"
1139            )
1140            return None
1141
1142        return model_path

Hack to get path for VMAF model from Homebrew or Linuxbrew. This works for libvmaf 2.x

Returns:

str or None: the path or None if not found

@staticmethod
def get_default_vmaf_model_path() -> str:
1144    @staticmethod
1145    def get_default_vmaf_model_path() -> str:
1146        """
1147        Return the default model path depending on whether the user is running Homebrew
1148        or has a static build.
1149
1150        Returns:
1151            str: the path
1152        """
1153        if has_brew() and ffmpeg_is_from_brew():
1154            # If the user installed ffmpeg using homebrew
1155            model_path = FfmpegQualityMetrics.get_brewed_vmaf_model_path()
1156            if model_path is not None:
1157                return os.path.join(
1158                    model_path,
1159                    "vmaf_v0.6.1.json",
1160                )
1161
1162        share_path = os.path.join("/usr", "local", "share", "model")
1163        if os.path.isdir(share_path):
1164            return os.path.join(share_path, "vmaf_v0.6.1.json")
1165        else:
1166            # return the bundled file as a fallback
1167            return os.path.join(
1168                FfmpegQualityMetrics.DEFAULT_VMAF_MODEL_DIRECTORY, "vmaf_v0.6.1.json"
1169            )

Return the default model path depending on whether the user is running Homebrew or has a static build.

Returns:

str: the path

@staticmethod
def get_supplied_vmaf_models() -> List[str]:
1171    @staticmethod
1172    def get_supplied_vmaf_models() -> List[str]:
1173        """
1174        Return a list of VMAF models supplied with the software.
1175
1176        Returns:
1177            List[str]: A list of VMAF model names
1178        """
1179        return sorted(
1180            f
1181            for f in os.listdir(FfmpegQualityMetrics.DEFAULT_VMAF_MODEL_DIRECTORY)
1182            if f.endswith(".json")
1183        )

Return a list of VMAF models supplied with the software.

Returns:

List[str]: A list of VMAF model names

def get_global_stats( self) -> Dict[Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad'], Dict[str, Dict[str, float]]]:
1185    def get_global_stats(self) -> GlobalStats:
1186        """
1187        Return a dictionary for each calculated metric, with different statstics
1188
1189        Returns:
1190            dict: A dictionary with stats, each key being a metric name and each value being a dictionary with the stats for every submetric. The stats are: 'average', 'median', 'stdev', 'min', 'max'.
1191        """
1192        for metric_name in self.data:
1193            logger.debug(f"Aggregating stats for {metric_name}")
1194            metric_data = self.data[metric_name]
1195            if len(metric_data) == 0:
1196                continue
1197            submetric_keys = [k for k in metric_data[0].keys() if k != "n"]
1198
1199            stats: Dict[str, GlobalStatsData] = {}
1200            for submetric_key in submetric_keys:
1201                values = [float(frame[submetric_key]) for frame in metric_data]
1202                # Filter out non-finite values (inf, -inf, nan) for robust statistics
1203                finite = [v for v in values if math.isfinite(v)]
1204                # Fallback to [0.0] if all values are non-finite to prevent crashes
1205                all_values = finite if finite else [0.0]
1206
1207                stats[submetric_key] = {
1208                    "average": round(float(mean(all_values)), 3),
1209                    "median": round(float(median(all_values)), 3),
1210                    "stdev": round(
1211                        float(pstdev(finite)) if len(finite) > 1 else 0.0, 3
1212                    ),
1213                    "min": round(min(all_values), 3),
1214                    "max": round(max(all_values), 3),
1215                }
1216            self.global_stats[metric_name] = stats
1217
1218        return self.global_stats

Return a dictionary for each calculated metric, with different statstics

Returns:

dict: A dictionary with stats, each key being a metric name and each value being a dictionary with the stats for every submetric. The stats are: 'average', 'median', 'stdev', 'min', 'max'.

def get_summary(self) -> str:
1220    def get_summary(self) -> str:
1221        """
1222        Return a short, human-readable table of the global stats (average, min, max)
1223        for the main submetric of each calculated metric.
1224
1225        The main submetrics are defined in `SUMMARY_SUBMETRICS`. If a metric has none of them,
1226        all of its submetrics are listed.
1227
1228        Returns:
1229            str: The summary table, or an empty string if no data was calculated
1230        """
1231        global_stats = self.get_global_stats()
1232        if not global_stats:
1233            return ""
1234
1235        rows: List[Tuple[str, GlobalStatsData]] = []
1236        for metric_name, metric_stats in global_stats.items():
1237            main_keys = [
1238                k
1239                for k in self.SUMMARY_SUBMETRICS.get(metric_name, [])
1240                if k in metric_stats
1241            ]
1242            for key in main_keys or list(metric_stats.keys()):
1243                label = key if key.startswith(metric_name) else f"{metric_name}_{key}"
1244                rows.append((label, metric_stats[key]))
1245
1246        n_frames = max(len(frames) for frames in self.data.values())
1247        label_width = max(len("metric"), *(len(label) for label, _ in rows))
1248
1249        lines = [
1250            f"Summary over {n_frames} frame{'s' if n_frames != 1 else ''}:",
1251            f"  {'metric':<{label_width}} {'average':>10} {'min':>10} {'max':>10}",
1252        ]
1253        for label, stats in rows:
1254            lines.append(
1255                f"  {label:<{label_width}} {stats['average']:>10.3f}"
1256                f" {stats['min']:>10.3f} {stats['max']:>10.3f}"
1257            )
1258        return "\n".join(lines)

Return a short, human-readable table of the global stats (average, min, max) for the main submetric of each calculated metric.

The main submetrics are defined in SUMMARY_SUBMETRICS. If a metric has none of them, all of its submetrics are listed.

Returns:

str: The summary table, or an empty string if no data was calculated

def get_results_csv(self) -> str:
1260    def get_results_csv(self) -> str:
1261        """
1262        Return a CSV string with the data
1263
1264        Returns:
1265            str: The CSV string
1266        """
1267        # Check if we have any data
1268        has_data = any(metric_data for metric_data in self.data.values())
1269        if not has_data:
1270            raise FfmpegQualityMetricsError("No data calculated!")
1271
1272        # Collect all frames and merge data by frame number
1273        frames_data: Dict[int, Dict[str, Union[str, float, int]]] = {}
1274
1275        # Process each metric's data
1276        for metric_data in self.data.values():
1277            if not metric_data:
1278                continue
1279
1280            for frame_info in cast(SingleMetricData, metric_data):
1281                frame_num = int(frame_info["n"])
1282                if frame_num not in frames_data:
1283                    frames_data[frame_num] = {"n": frame_num}
1284
1285                # Add all metric properties for this frame
1286                for key, value in frame_info.items():
1287                    if key != "n":  # Skip frame number as it's already added
1288                        frames_data[frame_num][key] = value
1289
1290        if not frames_data:
1291            raise FfmpegQualityMetricsError("No frame data found!")
1292
1293        # Sort frames by frame number
1294        sorted_frames = sorted(frames_data.keys())
1295
1296        # Collect all unique column names (excluding 'n' which we'll put first)
1297        all_columns: set[str] = set()
1298        for frame_data in frames_data.values():
1299            all_columns.update(frame_data.keys())
1300        all_columns.discard("n")
1301
1302        # Create column order: n first, then sorted metric columns, then input files
1303        columns = ["n"] + sorted(all_columns) + ["input_file_dist", "input_file_ref"]
1304
1305        # Generate CSV using StringIO and csv module
1306        output = StringIO()
1307        writer = csv.writer(output)
1308
1309        # Write header
1310        writer.writerow(columns)
1311
1312        # Write data rows
1313        for frame_num in sorted_frames:
1314            frame_data = frames_data[frame_num]
1315            row = []
1316            for col in columns:
1317                if col == "input_file_dist":
1318                    row.append(self.dist)
1319                elif col == "input_file_ref":
1320                    row.append(self.ref)
1321                else:
1322                    # Use the frame data value or empty string if not present
1323                    row.append(str(frame_data.get(col, "")))
1324            writer.writerow(row)
1325
1326        return output.getvalue()

Return a CSV string with the data

Returns:

str: The CSV string

def get_results_json(self) -> str:
1328    def get_results_json(self) -> str:
1329        """
1330        Return the results as JSON string
1331
1332        Returns:
1333            str: The JSON string
1334        """
1335        ret: Dict = {}
1336        for key in self.data:
1337            metric_data = self.data[key]
1338            if len(metric_data) == 0:
1339                continue
1340            ret[key] = metric_data
1341        ret["global"] = self.get_global_stats()
1342        ret["input_file_dist"] = self.dist
1343        ret["input_file_ref"] = self.ref
1344
1345        return json.dumps(ret, indent=4)

Return the results as JSON string

Returns:

str: The JSON string

class FfmpegQualityMetricsError(builtins.Exception):
107class FfmpegQualityMetricsError(Exception):
108    pass

Common base class for all non-exit exceptions.

class VmafOptions(typing.TypedDict):
38class VmafOptions(TypedDict):
39    """
40    VMAF-specific options.
41    """
42
43    model_path: Union[str, None]
44    """Use a specific VMAF model file. If none is chosen, picks a default model."""
45    model_params: List[str]
46    """A list of params to pass to the VMAF model, specified as key=value."""
47    n_threads: Union[int, None]
48    """Number of threads to use. Defaults to 0 (auto)."""
49    n_subsample: Union[int, None]
50    """Subsampling interval. Defaults to 1."""
51    features: List[str]
52    """
53    List of features to enable in addition to the default features.
54    Each entry must be a string beginning with name=feature_name, and additional parameters can be specified as
55    key=value, separated by colons.
56    """
57    ten_bit: bool
58    """
59    Convert both inputs to 10 bit (yuv420p10le) before calculating VMAF.
60    Recommended for VMAF v1 models, which should ideally be applied at 10-bit precision for SDR content.
61    Defaults to False.
62    """

VMAF-specific options.

model_path: str | None

Use a specific VMAF model file. If none is chosen, picks a default model.

model_params: List[str]

A list of params to pass to the VMAF model, specified as key=value.

n_threads: int | None

Number of threads to use. Defaults to 0 (auto).

n_subsample: int | None

Subsampling interval. Defaults to 1.

features: List[str]

List of features to enable in addition to the default features. Each entry must be a string beginning with name=feature_name, and additional parameters can be specified as key=value, separated by colons.

ten_bit: bool

Convert both inputs to 10 bit (yuv420p10le) before calculating VMAF. Recommended for VMAF v1 models, which should ideally be applied at 10-bit precision for SDR content. Defaults to False.

MetricName = typing.Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad']
SingleMetricData = typing.List[typing.Dict[str, float]]
GlobalStatsData = typing.Dict[str, float]
GlobalStats = typing.Dict[typing.Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad'], typing.Dict[str, typing.Dict[str, float]]]
MetricData = typing.Dict[typing.Literal['psnr', 'ssim', 'vmaf', 'vif', 'msad'], typing.List[typing.Dict[str, float]]]
class InputInfo(typing.TypedDict):
 84class InputInfo(TypedDict):
 85    """
 86    Properties of the first video stream of an input file, as reported by ffprobe.
 87    """
 88
 89    path: str
 90    """Path to the input file."""
 91    width: int
 92    """Width in pixels."""
 93    height: int
 94    """Height in pixels."""
 95    framerate: Union[float, None]
 96    """Frame rate in frames per second, or None if unknown."""
 97    duration: Union[float, None]
 98    """Duration in seconds, or None if unknown."""
 99    nb_frames: Union[int, None]
100    """Number of frames from the container metadata, or None if unknown."""

Properties of the first video stream of an input file, as reported by ffprobe.

path: str

Path to the input file.

width: int

Width in pixels.

height: int

Height in pixels.

framerate: float | None

Frame rate in frames per second, or None if unknown.

duration: float | None

Duration in seconds, or None if unknown.

nb_frames: int | None

Number of frames from the container metadata, or None if unknown.

__version__ = '3.13.0'