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]
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
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
Main submetrics of each metric, shown in the summary from get_summary().
Properties of the reference and distorted file, set by check_inputs().
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 ("
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
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
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
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.
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
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
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
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'.
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
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
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
Common base class for all non-exit exceptions.
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.
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.