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
- 异常时回退默认分数,保证产线不中断
This commit is contained in:
yumoqing 2026-06-25 21:10:35 +08:00
parent 8408570ed7
commit eb10d97aa2
2 changed files with 218 additions and 33 deletions

View File

@ -21,9 +21,12 @@ import asyncio
import json import json
import logging import logging
import os import os
import re
import tempfile import tempfile
from typing import Dict, Tuple, Optional from typing import Dict, Tuple, Optional
from pipeline_service.llm_bridge import llm_call
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# GPU 服务器配置(复用 ktv_adapter 的配置) # GPU 服务器配置(复用 ktv_adapter 的配置)
@ -373,10 +376,40 @@ async def evaluate_emotion_lyrics_match(
else: else:
audio_emotion = "柔和/舒缓" audio_emotion = "柔和/舒缓"
# TODO: 使用 LLM 分析歌词情感并与音频情绪比较 try:
# 这里返回模拟结果 llm_prompt = f"""分析歌词情感与音频特征的匹配度。
return 0.8, f"情绪-歌词匹配评估(音频情绪: {audio_emotion},待集成 LLM 歌词分析)" 歌词:
{lyrics}
音频情绪特征: {audio_emotion} (响度: {integrated_loudness:.1f} LUFS)
请评估歌词表达的情感是否与音频的情绪特征一致
1. 歌词的主题和情感倾向
2. 与音频情绪的匹配程度
请严格按照以下 JSON 格式返回不要输出其他内容
{{"match_score": <0到10的整数>, "reason": "简要说明匹配情况"}}
"""
response = await llm_call(llm_prompt, temperature=0.3)
# 提取 JSON兼容 markdown 代码块包裹)
json_match = re.search(r'\{[^{}]*\}', response)
if not json_match:
logger.warning(f"LLM 返回无法解析为 JSON: {response[:200]}")
return 0.6, f"LLM 返回格式异常,降级评估(音频情绪: {audio_emotion}"
data = json.loads(json_match.group())
match_score = float(data.get("match_score", 8))
reason = data.get("reason", "LLM 评估完成")
# 转换为 0-1 分数
score = max(0.0, min(1.0, match_score / 10.0))
return score, f"情绪-歌词匹配: {reason} (音频情绪: {audio_emotion})"
except Exception as e:
logger.warning(f"情绪-歌词匹配 LLM 评估失败: {e}")
return 0.6, f"LLM 分析异常,降级评估(音频情绪: {audio_emotion}: {e}"
async def evaluate_prompt_adherence( async def evaluate_prompt_adherence(
@ -395,10 +428,52 @@ async def evaluate_prompt_adherence(
if not prompt: if not prompt:
return 0.6, "无 prompt跳过指令匹配度评估" return 0.6, "无 prompt跳过指令匹配度评估"
# TODO: 使用 LLM 多模态分析音频与 prompt 的匹配度 try:
# 这里返回模拟结果 # 获取音频特征
metadata = await analyze_audio_metadata(audio_path)
return 0.8, f"指令匹配度评估(待集成 LLM 分析)" loudness = await analyze_audio_loudness(audio_path)
duration = metadata.get("duration", 0)
integrated_loudness = loudness.get("integrated_loudness", -70)
loudness_range = loudness.get("loudness_range", 0)
true_peak = loudness.get("true_peak", -100)
llm_prompt = f"""评估一段 AI 生成的音频与生成指令之间的匹配度。
生成指令 (prompt)
{prompt}
音频特征从音频信号中提取
- 时长: {duration:.1f}
- 综合响度: {integrated_loudness:.1f} LUFS
- 响度范围: {loudness_range:.1f} LU
- 真峰值: {true_peak:.1f} dBTP
请根据以上信息评估指令中描述的风格情绪乐器速度等特征是否在音频的物理特性中得到体现
注意你无法直接听到音频只能通过上述特征进行推断请结合常识分析例如高响度+窄响度范围通常对应激烈/电子风格低响度+宽响度范围通常对应舒缓/原声风格
请严格按照以下 JSON 格式返回不要输出其他内容
{{"match_score": <0到10的整数>, "reason": "简要说明匹配情况"}}
"""
response = await llm_call(llm_prompt, temperature=0.3)
# 提取 JSON兼容 markdown 代码块包裹)
json_match = re.search(r'\{[^{}]*\}', response)
if not json_match:
logger.warning(f"LLM 返回无法解析为 JSON: {response[:200]}")
return 0.6, "LLM 返回格式异常,降级评估"
data = json.loads(json_match.group())
match_score = float(data.get("match_score", 8))
reason = data.get("reason", "LLM 评估完成")
# 转换为 0-1 分数
score = max(0.0, min(1.0, match_score / 10.0))
return score, f"指令匹配: {reason}"
except Exception as e:
logger.warning(f"指令匹配度 LLM 评估失败: {e}")
return 0.6, f"LLM 分析异常,降级评估: {e}"
async def evaluate_accompaniment_quality( async def evaluate_accompaniment_quality(

View File

@ -15,15 +15,68 @@
""" """
import asyncio import asyncio
import base64
import json import json
import logging import logging
import os import os
import re
import tempfile import tempfile
from typing import Dict, Tuple, Optional from typing import Dict, Tuple, Optional
from pipeline_service.llm_bridge import llm_call
logger = logging.getLogger(__name__) 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: async def analyze_video_metadata(video_path: str) -> Dict:
""" """
使用 ffprobe 提取视频元数据 使用 ffprobe 提取视频元数据
@ -239,8 +292,20 @@ async def evaluate_semantic_consistency(
if not frames: if not frames:
return 0.5, "无法提取视频帧,跳过语义评估" return 0.5, "无法提取视频帧,跳过语义评估"
# 构建 LLM prompt # 将关键帧编码为 base64
llm_prompt = f"""分析以下视频帧与描述的匹配度: 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} 描述{prompt}
@ -249,24 +314,25 @@ async def evaluate_semantic_consistency(
2. 氛围和情绪是否匹配 2. 氛围和情绪是否匹配
3. 整体视觉风格是否符合预期 3. 整体视觉风格是否符合预期
返回 JSON 格式 请仅返回 JSON 格式不要其他文字
{{ {{"match_score": 0-10, "reason": "简要说明匹配情况"}}"""
"match_score": 0-10,
"reason": "简要说明匹配情况" full_prompt = _build_multimodal_prompt_with_frames(text_instruction, frames_b64)
}}
"""
try: try:
# 调用 LLM 多模态(这里使用占位实现,实际应调用 vision_analyze 或类似工具) result = await llm_call(full_prompt, temperature=0.3)
# 暂时返回中性分数 data = _parse_llm_json_response(result)
# TODO: 集成实际的 LLM 多模态调用
match_score = data.get("match_score", 8)
# 模拟 LLM 返回 reason = data.get("reason", "LLM 评估完成")
return 0.8, "语义一致性评估(待集成 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: except Exception as e:
logger.warning(f"语义一致性评估失败: {e}") logger.warning(f"语义一致性 LLM 评估失败: {e}")
return 0.5, f"语义评估异常: {e}" return 0.5, f"语义一致性评估异常: {e}"
async def evaluate_character_consistency( async def evaluate_character_consistency(
@ -296,15 +362,59 @@ async def evaluate_character_consistency(
if not frames: if not frames:
return 0.5, "无法提取视频帧,跳过角色一致性评估" return 0.5, "无法提取视频帧,跳过角色一致性评估"
# 将参考图编码为 base64
try: try:
# 调用 LLM 多模态比较 ref_b64 = _encode_image_to_base64(reference_image)
# TODO: 集成实际的 LLM 多模态调用
# 模拟返回
return 0.85, "角色一致性评估(待集成 LLM 多模态)"
except Exception as e: except Exception as e:
logger.warning(f"角色一致性评估失败: {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}" return 0.5, f"角色一致性评估异常: {e}"