788559ff29
CI/CD Pipeline / Frontend Lint (push) Successful in 48s
CI/CD Pipeline / Validate Code Quality And Tests (push) Successful in 1m6s
CI/CD Pipeline / Build Production Runtime Images (push) Has been skipped
CI/CD Pipeline / Deploy Production (push) Has been skipped
CI/CD Pipeline / Production Browser E2E (push) Has been skipped
CI/CD Pipeline / Integration Tests (push) Successful in 1m22s
CI/CD Pipeline / Build & Push Staging (Watchtower auto-deploy) (push) Successful in 53m17s
CI/CD Pipeline / Staging E2E Tests (push) Successful in 1m10s
CI/CD Pipeline / Staging API Integration Tests (push) Successful in 3m16s
284 lines
8.0 KiB
Python
284 lines
8.0 KiB
Python
"""视频对比工具 — 基于 FFmpeg 的像素级质量对比.
|
||
|
||
使用 SSIM + PSNR 双指标评估两个视频的相似度:
|
||
- SSIM (Structural Similarity): 结构相似性,范围 [0, 1],越接近 1 越相似
|
||
- PSNR (Peak Signal-to-Noise Ratio): 峰值信噪比,单位 dB,越高越好
|
||
|
||
灰度验收标准:
|
||
- 平均 SSIM >= 0.95 → 视觉上几乎无差异(P0 场景必达)
|
||
- 最低 SSIM >= 0.90 → 最严重帧差异可接受
|
||
- 平均 PSNR >= 28dB → 质量达标
|
||
"""
|
||
|
||
from __future__ import annotations
|
||
|
||
import json
|
||
import re
|
||
import shutil
|
||
import subprocess # nosec B404
|
||
from dataclasses import asdict, dataclass
|
||
from pathlib import Path
|
||
from typing import Any
|
||
|
||
FFMPEG_BIN: str = shutil.which("ffmpeg") or "ffmpeg"
|
||
FFPROBE_BIN: str = shutil.which("ffprobe") or "ffprobe"
|
||
|
||
|
||
@dataclass
|
||
class VideoDiffResult:
|
||
"""视频对比结果."""
|
||
|
||
video_a: str
|
||
video_b: str
|
||
width: int
|
||
height: int
|
||
duration_a: float
|
||
duration_b: float
|
||
avg_ssim: float
|
||
min_ssim: float
|
||
avg_psnr: float # dB
|
||
min_psnr: float
|
||
frame_count: int
|
||
duration_diff: float # 时长差(秒)
|
||
resolution_match: bool
|
||
passed: bool # 是否通过阈值
|
||
|
||
def to_dict(self) -> dict[str, Any]:
|
||
return asdict(self)
|
||
|
||
|
||
def probe_video_info(video_path: str) -> dict[str, Any]:
|
||
"""获取视频信息(宽、高、时长、fps)."""
|
||
try:
|
||
result = subprocess.run( # nosec B603
|
||
[
|
||
FFPROBE_BIN,
|
||
"-v",
|
||
"error",
|
||
"-select_streams",
|
||
"v:0",
|
||
"-show_entries",
|
||
"stream=width,height,r_frame_rate,duration",
|
||
"-show_entries",
|
||
"format=duration",
|
||
"-of",
|
||
"json",
|
||
video_path,
|
||
],
|
||
check=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
timeout=10,
|
||
)
|
||
info = json.loads(result.stdout)
|
||
stream = info.get("streams", [{}])[0]
|
||
fmt = info.get("format", {})
|
||
|
||
width = int(stream.get("width", 1280))
|
||
height = int(stream.get("height", 720))
|
||
fps_str = stream.get("r_frame_rate", "25/1")
|
||
if "/" in fps_str:
|
||
num, den = fps_str.split("/")
|
||
fps = float(num) / float(den) if float(den) > 0 else 25.0
|
||
else:
|
||
fps = float(fps_str) if fps_str else 25.0
|
||
duration = float(fmt.get("duration", 0)) or float(stream.get("duration", 0))
|
||
|
||
return {"width": width, "height": height, "duration": duration, "fps": round(fps, 2)}
|
||
except Exception:
|
||
return {"width": 1280, "height": 720, "duration": 0.0, "fps": 25.0}
|
||
|
||
|
||
def compute_video_diff(
|
||
video_a: str | Path,
|
||
video_b: str | Path,
|
||
*,
|
||
ssim_threshold: float = 0.95,
|
||
psnr_threshold: float = 28.0,
|
||
duration_tolerance: float = 0.1,
|
||
) -> VideoDiffResult:
|
||
"""计算两个视频的像素差异.
|
||
|
||
使用 FFmpeg ssim + psnr 滤镜一次性计算两个指标。
|
||
|
||
Args:
|
||
video_a: 视频A路径(基线)
|
||
video_b: 视频B路径(对比)
|
||
ssim_threshold: SSIM 合格阈值(默认 0.90)
|
||
psnr_threshold: PSNR 合格阈值(默认 25dB)
|
||
duration_tolerance: 时长容忍度(秒,默认 0.1s)
|
||
|
||
Returns:
|
||
VideoDiffResult 对比结果
|
||
|
||
Raises:
|
||
subprocess.CalledProcessError: FFmpeg 执行失败
|
||
"""
|
||
info_a = probe_video_info(str(video_a))
|
||
info_b = probe_video_info(str(video_b))
|
||
|
||
duration_diff = abs(info_a["duration"] - info_b["duration"])
|
||
resolution_match = info_a["width"] == info_b["width"] and info_a["height"] == info_b["height"]
|
||
|
||
# ssim 和 psnr 的 stats_file 都输出到 stdout
|
||
# 用行格式区分:SSIM 行含 "All:",PSNR 行含 "psnr_avg:"
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-i",
|
||
str(video_a),
|
||
"-i",
|
||
str(video_b),
|
||
"-lavfi",
|
||
"[0:v][1:v]ssim=stats_file=-[out1];[0:v][1:v]psnr=stats_file=-[out2]",
|
||
"-f",
|
||
"null",
|
||
"-",
|
||
]
|
||
|
||
result = subprocess.run( # nosec B603
|
||
command,
|
||
check=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.PIPE,
|
||
text=True,
|
||
timeout=300,
|
||
)
|
||
|
||
# 逐帧统计在 stdout(stats_file=-),汇总日志在 stderr
|
||
stats_stdout = result.stdout or ""
|
||
|
||
avg_ssim, min_ssim = _parse_ssim_stats(stats_stdout)
|
||
avg_psnr, min_psnr = _parse_psnr_stats(stats_stdout)
|
||
frame_count = _count_frames(result.stderr or "")
|
||
|
||
passed = (
|
||
resolution_match
|
||
and duration_diff <= duration_tolerance
|
||
and avg_ssim >= ssim_threshold
|
||
and avg_psnr >= psnr_threshold
|
||
)
|
||
|
||
return VideoDiffResult(
|
||
video_a=str(video_a),
|
||
video_b=str(video_b),
|
||
width=info_a["width"],
|
||
height=info_a["height"],
|
||
duration_a=round(info_a["duration"], 3),
|
||
duration_b=round(info_b["duration"], 3),
|
||
avg_ssim=round(avg_ssim, 6),
|
||
min_ssim=round(min_ssim, 6),
|
||
avg_psnr=round(avg_psnr, 3),
|
||
min_psnr=round(min_psnr, 3),
|
||
frame_count=frame_count,
|
||
duration_diff=round(duration_diff, 3),
|
||
resolution_match=resolution_match,
|
||
passed=passed,
|
||
)
|
||
|
||
|
||
def _parse_ssim_stats(stats_output: str) -> tuple[float, float]:
|
||
"""从 SSIM stats_file 输出中解析逐帧 SSIM.
|
||
|
||
FFmpeg ssim 滤镜 stats_file 输出格式(每行一帧):
|
||
n:1 Y:0.987654 U:0.991234 V:0.990000 All:0.989000 (19.585642)
|
||
n:2 Y:0.986543 U:0.990123 V:0.988888 All:0.987654 (19.123456)
|
||
...
|
||
|
||
Returns:
|
||
(avg_ssim, min_ssim)
|
||
"""
|
||
ssim_values: list[float] = []
|
||
|
||
for line in stats_output.split("\n"):
|
||
# 匹配 stats_file 格式:n:数字 ... All:数字
|
||
if not line.startswith("n:"):
|
||
continue
|
||
match = re.search(r"All:(\d+\.\d+)", line)
|
||
if match:
|
||
ssim_values.append(float(match.group(1)))
|
||
|
||
if not ssim_values:
|
||
return 0.0, 0.0
|
||
|
||
avg_ssim = sum(ssim_values) / len(ssim_values)
|
||
min_ssim = min(ssim_values)
|
||
return avg_ssim, min_ssim
|
||
|
||
|
||
def _parse_psnr_stats(stats_output: str) -> tuple[float, float]:
|
||
"""从 PSNR stats_file 输出中解析逐帧 PSNR.
|
||
|
||
FFmpeg psnr 滤镜 stats_file 输出格式(每行一帧):
|
||
n:1 mse_avg:100.23 mse_y:150.12 mse_u:50.34 mse_v:80.56 psnr_avg:28.12 psnr_y:26.34 psnr_u:31.12 psnr_v:29.08
|
||
n:2 ...
|
||
|
||
Returns:
|
||
(avg_psnr, min_psnr) — avg_psnr 是逐帧 psnr_avg 的均值,min_psnr 是逐帧最小值
|
||
"""
|
||
psnr_values: list[float] = []
|
||
|
||
for line in stats_output.split("\n"):
|
||
if not line.startswith("n:"):
|
||
continue
|
||
match = re.search(r"psnr_avg:(\d+\.\d+)", line)
|
||
if match:
|
||
psnr_values.append(float(match.group(1)))
|
||
|
||
if not psnr_values:
|
||
return 0.0, 0.0
|
||
|
||
avg_psnr = sum(psnr_values) / len(psnr_values)
|
||
min_psnr = min(psnr_values)
|
||
return avg_psnr, min_psnr
|
||
|
||
|
||
def _count_frames(stderr: str) -> int:
|
||
"""从 FFmpeg 输出中统计帧数."""
|
||
match = re.search(r"frame=\s*(\d+)", stderr)
|
||
return int(match.group(1)) if match else 0
|
||
|
||
|
||
def save_diff_frame(
|
||
video_a: str | Path,
|
||
video_b: str | Path,
|
||
output_path: str | Path,
|
||
*,
|
||
timestamp: float = 1.0,
|
||
) -> Path:
|
||
"""生成差异帧可视化图(红绿色差).
|
||
|
||
使用 blend 滤镜生成差异可视化图,差异越大越亮。
|
||
|
||
Args:
|
||
video_a: 视频A
|
||
video_b: 视频B
|
||
output_path: 输出图片路径
|
||
timestamp: 截取的时间点(秒)
|
||
|
||
Returns:
|
||
输出图片路径
|
||
"""
|
||
command = [
|
||
FFMPEG_BIN,
|
||
"-y",
|
||
"-ss",
|
||
str(timestamp),
|
||
"-i",
|
||
str(video_a),
|
||
"-ss",
|
||
str(timestamp),
|
||
"-i",
|
||
str(video_b),
|
||
"-lavfi",
|
||
"[0:v][1:v]blend=all_mode=difference,eq=contrast=5:brightness=0.5[diff]",
|
||
"-map",
|
||
"[diff]",
|
||
"-vframes",
|
||
"1",
|
||
str(output_path),
|
||
]
|
||
|
||
subprocess.run(command, check=True, capture_output=True, timeout=60) # nosec B603
|
||
return Path(output_path)
|