pipeline-app/app/eval_video.py
yumoqing eb10d97aa2 feat: eval_video/eval_music 接入 LLM 评估
- eval_video.py: semantic_consistency + character_consistency 调用 llm_bridge
- eval_music.py: emotion_lyrics_match + prompt_adherence 调用 llm_bridge
- 异常时回退默认分数,保证产线不中断
2026-06-25 21:10:35 +08:00

520 lines
16 KiB
Python
Raw Permalink 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 base64
import json
import logging
import os
import re
import tempfile
from typing import Dict, Tuple, Optional
from pipeline_service.llm_bridge import llm_call
logger = logging.getLogger(__name__)
def _encode_image_to_base64(image_path: str) -> str:
"""将图片文件读取并编码为 base64 字符串"""
with open(image_path, "rb") as f:
return base64.b64encode(f.read()).decode("utf-8")
def _parse_llm_json_response(response: str) -> dict:
"""从 LLM 返回文本中解析 JSON支持 markdown 代码块"""
# 尝试直接解析
try:
return json.loads(response)
except json.JSONDecodeError:
pass
# 尝试从 markdown 代码块中提取
json_match = re.search(r"```(?:json)?\s*\n?(.*?)\n?```", response, re.DOTALL)
if json_match:
try:
return json.loads(json_match.group(1))
except json.JSONDecodeError:
pass
# 尝试提取花括号内的内容
brace_match = re.search(r"\{.*\}", response, re.DOTALL)
if brace_match:
try:
return json.loads(brace_match.group(0))
except json.JSONDecodeError:
pass
logger.warning(f"无法从 LLM 响应中解析 JSON: {response[:200]}")
return {}
def _build_multimodal_prompt_with_frames(text_prompt: str, frames_b64: list) -> str:
"""
构建包含 base64 图片的多模态 prompt
将图片以 data URI 格式嵌入 prompt 文本,供多模态 LLM 解析
"""
image_parts = []
for i, b64 in enumerate(frames_b64):
image_parts.append(f"[Frame {i+1} image data: data:image/jpeg;base64,{b64}]")
images_section = "\n".join(image_parts)
full_prompt = f"{images_section}\n\n{text_prompt}"
return full_prompt
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, "无法提取视频帧,跳过语义评估"
# 将关键帧编码为 base64
frames_b64 = []
for frame_path in frames[:3]:
if os.path.exists(frame_path):
try:
frames_b64.append(_encode_image_to_base64(frame_path))
except Exception as e:
logger.warning(f"无法编码帧 {frame_path}: {e}")
if not frames_b64:
return 0.5, "无法编码视频帧为 base64跳过语义评估"
# 构建包含图片的多模态 prompt
text_instruction = f"""分析以下视频帧与描述的匹配度:
描述:{prompt}
请评估:
1. 视频内容是否准确反映了描述中的场景、物体、动作
2. 氛围和情绪是否匹配
3. 整体视觉风格是否符合预期
请仅返回 JSON 格式(不要其他文字):
{{"match_score": 0-10, "reason": "简要说明匹配情况"}}"""
full_prompt = _build_multimodal_prompt_with_frames(text_instruction, frames_b64)
try:
result = await llm_call(full_prompt, temperature=0.3)
data = _parse_llm_json_response(result)
match_score = data.get("match_score", 8)
reason = data.get("reason", "LLM 评估完成")
# 转换为 0-1 分数match_score 范围 0-10
score = min(1.0, max(0.0, match_score / 10.0))
return score, f"语义一致性: {reason}"
except Exception as e:
logger.warning(f"语义一致性 LLM 评估失败: {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, "无法提取视频帧,跳过角色一致性评估"
# 将参考图编码为 base64
try:
ref_b64 = _encode_image_to_base64(reference_image)
except Exception as e:
logger.warning(f"无法编码参考图 {reference_image}: {e}")
return 0.5, f"无法编码参考图: {e}"
# 将关键帧编码为 base64
frames_b64 = []
for frame_path in frames[:3]:
if os.path.exists(frame_path):
try:
frames_b64.append(_encode_image_to_base64(frame_path))
except Exception as e:
logger.warning(f"无法编码帧 {frame_path}: {e}")
if not frames_b64:
return 0.5, "无法编码视频帧为 base64跳过角色一致性评估"
# 构建包含参考图和关键帧的多模态 prompt
# 参考图放在最前面,标记为 Reference
all_b64 = [ref_b64] + frames_b64
image_parts = []
image_parts.append(f"[Reference image data: data:image/jpeg;base64,{ref_b64}]")
for i, b64 in enumerate(frames_b64):
image_parts.append(f"[Frame {i+1} image data: data:image/jpeg;base64,{b64}]")
text_instruction = """比较上方的参考图Reference和视频帧Frame 1-3中的人物特征评估角色一致性。
请评估:
1. 面部特征的一致性(五官、发型、肤色)
2. 服装和外观的保持度
3. 姿态和动作的自然性
请仅返回 JSON 格式(不要其他文字):
{"consistency_score": 0-10, "reason": "简要说明一致性情况"}"""
images_section = "\n".join(image_parts)
full_prompt = f"{images_section}\n\n{text_instruction}"
try:
result = await llm_call(full_prompt, temperature=0.3)
data = _parse_llm_json_response(result)
consistency_score = data.get("consistency_score", 8)
reason = data.get("reason", "LLM 评估完成")
# 转换为 0-1 分数consistency_score 范围 0-10
score = min(1.0, max(0.0, consistency_score / 10.0))
return score, f"角色一致性: {reason}"
except Exception as e:
logger.warning(f"角色一致性 LLM 评估失败: {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
}