pipeline-app/app/eval_video.py
yumoqing 9d3f0ff38c feat: 多维度质量评估体系 - 视频5维度/音乐7维度
新增模块:
- app/eval_video.py: 场景视频多维度评估 (视觉质量25%, 运动流畅度20%, 语义一致性30%, 角色一致性15%, 时间连贯性10%)
- app/eval_music.py: 音乐多维度评估 (节奏15%, 和弦10%, 听感20%, 情绪-歌词匹配20%, 指令匹配15%, 伴奏10%, 人声10%)

更新模块:
- app/quality_gate.py: 集成多维度评估函数,支持降级
- app/ktv_adapter.py: 注册6个质量门控 handlers (步骤9-10, 11, 12, 13, 14-15, 17)

质量门控流程:
1. 执行 handler
2. 多维度评估
3. 不达标自动重试(最多3次)
4. 仍不达标创建 human_task 暂停任务等待人工决策
2026-06-25 18:34:26 +08:00

410 lines
12 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
视频生成质量评估 - 多维度评估框架
评估维度:
1. 视觉质量 (25%) - 分辨率、清晰度、色彩
2. 运动流畅度 (20%) - 帧率稳定性、运动伪影
3. 语义一致性 (30%) - 视频内容与 prompt 描述的匹配度
4. 角色一致性 (15%) - Ref2V 生成时角色特征保持度
5. 时间连贯性 (10%) - 无闪烁、跳变
工具:
- ffprobe: 视频元数据、帧率
- ffmpeg: 帧提取、质量分析
- LLM 多模态: 语义理解、角色匹配
"""
import asyncio
import json
import logging
import os
import tempfile
from typing import Dict, Tuple, Optional
logger = logging.getLogger(__name__)
async def analyze_video_metadata(video_path: str) -> Dict:
"""
使用 ffprobe 提取视频元数据
返回:
{
"width": int,
"height": int,
"fps": float,
"duration": float,
"codec": str,
"bitrate": int
}
"""
try:
proc = await asyncio.create_subprocess_exec(
"ffprobe",
"-v", "error",
"-select_streams", "v:0",
"-show_entries", "stream=width,height,r_frame_rate,codec_name,bit_rate",
"-show_entries", "format=duration",
"-of", "json",
video_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
stdout, stderr = await proc.communicate()
if proc.returncode != 0:
logger.warning(f"ffprobe failed: {stderr.decode()}")
return {}
data = json.loads(stdout.decode())
stream = data.get("streams", [{}])[0]
fmt = data.get("format", {})
# 解析帧率
fps_str = stream.get("r_frame_rate", "30/1")
if "/" in fps_str:
num, den = fps_str.split("/")
fps = float(num) / float(den) if float(den) > 0 else 30.0
else:
fps = float(fps_str)
return {
"width": stream.get("width", 0),
"height": stream.get("height", 0),
"fps": fps,
"duration": float(fmt.get("duration", 0)),
"codec": stream.get("codec_name", "unknown"),
"bitrate": int(stream.get("bit_rate", 0))
}
except Exception as e:
logger.error(f"Failed to analyze video metadata: {e}")
return {}
async def extract_key_frames(video_path: str, num_frames: int = 5) -> list:
"""
从视频中提取关键帧
返回: 帧图片路径列表
"""
try:
# 获取视频时长
metadata = await analyze_video_metadata(video_path)
duration = metadata.get("duration", 0)
if duration <= 0:
return []
# 均匀提取帧
interval = duration / (num_frames + 1)
frames = []
with tempfile.TemporaryDirectory() as tmpdir:
for i in range(num_frames):
timestamp = interval * (i + 1)
output_path = os.path.join(tmpdir, f"frame_{i:03d}.jpg")
proc = await asyncio.create_subprocess_exec(
"ffmpeg",
"-ss", str(timestamp),
"-i", video_path,
"-vframes", "1",
"-q:v", "2",
output_path,
stdout=asyncio.subprocess.PIPE,
stderr=asyncio.subprocess.PIPE
)
await proc.communicate()
if os.path.exists(output_path):
frames.append(output_path)
# 复制到持久化位置(如果需要)
return frames
except Exception as e:
logger.error(f"Failed to extract key frames: {e}")
return []
async def evaluate_visual_quality(video_path: str) -> Tuple[float, str]:
"""
评估视觉质量
检查项:
- 分辨率 >= 720p (1280x720)
- 码率合理性(避免过度压缩)
- 帧率 >= 24fps
返回: (score 0-1, reason)
"""
metadata = await analyze_video_metadata(video_path)
if not metadata:
return 0.0, "无法读取视频元数据"
width = metadata.get("width", 0)
height = metadata.get("height", 0)
fps = metadata.get("fps", 0)
bitrate = metadata.get("bitrate", 0)
score = 0.0
reasons = []
# 分辨率评分
pixels = width * height
if pixels >= 1920 * 1080: # 1080p+
score += 0.4
reasons.append(f"高分辨率 {width}x{height}")
elif pixels >= 1280 * 720: # 720p
score += 0.3
reasons.append(f"标准分辨率 {width}x{height}")
elif pixels >= 640 * 480: # 480p
score += 0.2
reasons.append(f"较低分辨率 {width}x{height}")
else:
reasons.append(f"分辨率过低 {width}x{height}")
# 帧率评分
if fps >= 29:
score += 0.3
reasons.append(f"流畅帧率 {fps:.1f}fps")
elif fps >= 24:
score += 0.2
reasons.append(f"标准帧率 {fps:.1f}fps")
else:
reasons.append(f"帧率过低 {fps:.1f}fps")
# 码率评分(经验值)
if bitrate >= 2000000: # 2Mbps+
score += 0.3
reasons.append(f"码率充足 {bitrate//1000}kbps")
elif bitrate >= 1000000:
score += 0.2
reasons.append(f"码率一般 {bitrate//1000}kbps")
else:
reasons.append(f"码率过低 {bitrate//1000}kbps")
return score, "; ".join(reasons)
async def evaluate_motion_smoothness(video_path: str) -> Tuple[float, str]:
"""
评估运动流畅度
检查项:
- 帧率稳定性
- 运动伪影(通过帧差分析)
返回: (score 0-1, reason)
"""
metadata = await analyze_video_metadata(video_path)
if not metadata:
return 0.0, "无法读取视频元数据"
fps = metadata.get("fps", 0)
# 简单评估:帧率 >= 24fps 认为流畅
if fps >= 29:
return 0.9, f"运动流畅 {fps:.1f}fps"
elif fps >= 24:
return 0.7, f"运动基本流畅 {fps:.1f}fps"
elif fps >= 20:
return 0.5, f"运动略显卡顿 {fps:.1f}fps"
else:
return 0.3, f"运动不流畅 {fps:.1f}fps"
async def evaluate_semantic_consistency(
video_path: str,
prompt: str,
frames: Optional[list] = None
) -> Tuple[float, str]:
"""
评估语义一致性 - 视频内容与 prompt 描述的匹配度
使用 LLM 多模态能力:
1. 提取关键帧
2. 让 LLM 分析帧内容与 prompt 的匹配度
返回: (score 0-1, reason)
"""
if not prompt:
return 0.5, "无 prompt无法评估语义一致性"
# 提取关键帧
if not frames:
frames = await extract_key_frames(video_path, num_frames=3)
if not frames:
return 0.5, "无法提取视频帧,跳过语义评估"
# 构建 LLM prompt
llm_prompt = f"""分析以下视频帧与描述的匹配度:
描述:{prompt}
请评估:
1. 视频内容是否准确反映了描述中的场景、物体、动作
2. 氛围和情绪是否匹配
3. 整体视觉风格是否符合预期
返回 JSON 格式:
{{
"match_score": 0-10,
"reason": "简要说明匹配情况"
}}
"""
try:
# 调用 LLM 多模态(这里使用占位实现,实际应调用 vision_analyze 或类似工具)
# 暂时返回中性分数
# TODO: 集成实际的 LLM 多模态调用
# 模拟 LLM 返回
return 0.8, "语义一致性评估(待集成 LLM 多模态)"
except Exception as e:
logger.warning(f"语义一致性评估失败: {e}")
return 0.5, f"语义评估异常: {e}"
async def evaluate_character_consistency(
video_path: str,
reference_image: Optional[str],
frames: Optional[list] = None
) -> Tuple[float, str]:
"""
评估角色一致性 - Ref2V 生成时角色特征保持度
使用 LLM 多模态:
1. 比较参考图和视频帧中的人物
2. 评估面部特征、服装、姿态的一致性
返回: (score 0-1, reason)
"""
if not reference_image:
return 1.0, "非 Ref2V 模式,跳过角色一致性评估"
if not os.path.exists(reference_image):
return 0.5, f"参考图不存在: {reference_image}"
# 提取关键帧
if not frames:
frames = await extract_key_frames(video_path, num_frames=3)
if not frames:
return 0.5, "无法提取视频帧,跳过角色一致性评估"
try:
# 调用 LLM 多模态比较
# TODO: 集成实际的 LLM 多模态调用
# 模拟返回
return 0.85, "角色一致性评估(待集成 LLM 多模态)"
except Exception as e:
logger.warning(f"角色一致性评估失败: {e}")
return 0.5, f"角色一致性评估异常: {e}"
async def evaluate_temporal_coherence(video_path: str) -> Tuple[float, str]:
"""
评估时间连贯性 - 检测闪烁、跳变、伪影
方法:
1. 分析相邻帧的差异
2. 检测异常跳变
返回: (score 0-1, reason)
"""
# 简单实现:假设大部分生成视频时间连贯性良好
# 实际可以通过 ffmpeg 的 frame diff 分析实现
metadata = await analyze_video_metadata(video_path)
duration = metadata.get("duration", 0)
# 视频时长越长,时间连贯性问题越容易暴露
if duration >= 5:
return 0.8, "时间连贯性良好(基于时长推断)"
else:
return 0.7, "时间连贯性一般(视频较短)"
async def evaluate_scene_video_quality(
video_path: str,
prompt: str,
reference_image: Optional[str] = None
) -> Dict:
"""
场景视频质量综合评估
参数:
video_path: 视频文件路径
prompt: 生成时的描述
reference_image: Ref2V 的参考图(可选)
返回:
{
"overall_score": float (0-1),
"overall_reason": str,
"dimensions": {
"visual_quality": {"score": float, "weight": 0.25, "reason": str},
"motion_smoothness": {"score": float, "weight": 0.20, "reason": str},
"semantic_consistency": {"score": float, "weight": 0.30, "reason": str},
"character_consistency": {"score": float, "weight": 0.15, "reason": str},
"temporal_coherence": {"score": float, "weight": 0.10, "reason": str}
}
}
"""
# 基础检查
if not os.path.exists(video_path):
return {
"overall_score": 0.0,
"overall_reason": f"视频文件不存在: {video_path}",
"dimensions": {}
}
file_size = os.path.getsize(video_path)
if file_size < 10240: # < 10KB
return {
"overall_score": 0.0,
"overall_reason": f"视频文件过小: {file_size} bytes",
"dimensions": {}
}
# 提取关键帧(用于多个维度)
frames = await extract_key_frames(video_path, num_frames=3)
# 评估各维度
visual_score, visual_reason = await evaluate_visual_quality(video_path)
motion_score, motion_reason = await evaluate_motion_smoothness(video_path)
semantic_score, semantic_reason = await evaluate_semantic_consistency(video_path, prompt, frames)
character_score, character_reason = await evaluate_character_consistency(video_path, reference_image, frames)
temporal_score, temporal_reason = await evaluate_temporal_coherence(video_path)
# 加权计算总分
dimensions = {
"visual_quality": {"score": visual_score, "weight": 0.25, "reason": visual_reason},
"motion_smoothness": {"score": motion_score, "weight": 0.20, "reason": motion_reason},
"semantic_consistency": {"score": semantic_score, "weight": 0.30, "reason": semantic_reason},
"character_consistency": {"score": character_score, "weight": 0.15, "reason": character_reason},
"temporal_coherence": {"score": temporal_score, "weight": 0.10, "reason": temporal_reason},
}
overall_score = sum(d["score"] * d["weight"] for d in dimensions.values())
# 生成综合原因
reasons = []
for dim_name, dim_data in dimensions.items():
if dim_data["score"] < 0.6:
reasons.append(f"{dim_name}: {dim_data['reason']}")
overall_reason = "; ".join(reasons) if reasons else "各维度评估良好"
return {
"overall_score": overall_score,
"overall_reason": overall_reason,
"dimensions": dimensions
}