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:
parent
8408570ed7
commit
eb10d97aa2
@ -21,9 +21,12 @@ import asyncio
|
||||
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__)
|
||||
|
||||
# GPU 服务器配置(复用 ktv_adapter 的配置)
|
||||
@ -373,10 +376,40 @@ async def evaluate_emotion_lyrics_match(
|
||||
else:
|
||||
audio_emotion = "柔和/舒缓"
|
||||
|
||||
# TODO: 使用 LLM 分析歌词情感并与音频情绪比较
|
||||
# 这里返回模拟结果
|
||||
|
||||
return 0.8, f"情绪-歌词匹配评估(音频情绪: {audio_emotion},待集成 LLM 歌词分析)"
|
||||
try:
|
||||
llm_prompt = f"""分析歌词情感与音频特征的匹配度。
|
||||
|
||||
歌词:
|
||||
{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(
|
||||
@ -395,10 +428,52 @@ async def evaluate_prompt_adherence(
|
||||
if not prompt:
|
||||
return 0.6, "无 prompt,跳过指令匹配度评估"
|
||||
|
||||
# TODO: 使用 LLM 多模态分析音频与 prompt 的匹配度
|
||||
# 这里返回模拟结果
|
||||
|
||||
return 0.8, f"指令匹配度评估(待集成 LLM 分析)"
|
||||
try:
|
||||
# 获取音频特征
|
||||
metadata = await analyze_audio_metadata(audio_path)
|
||||
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(
|
||||
|
||||
@ -15,15 +15,68 @@
|
||||
"""
|
||||
|
||||
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 提取视频元数据
|
||||
@ -239,8 +292,20 @@ async def evaluate_semantic_consistency(
|
||||
if not frames:
|
||||
return 0.5, "无法提取视频帧,跳过语义评估"
|
||||
|
||||
# 构建 LLM prompt
|
||||
llm_prompt = f"""分析以下视频帧与描述的匹配度:
|
||||
# 将关键帧编码为 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}
|
||||
|
||||
@ -249,24 +314,25 @@ async def evaluate_semantic_consistency(
|
||||
2. 氛围和情绪是否匹配
|
||||
3. 整体视觉风格是否符合预期
|
||||
|
||||
返回 JSON 格式:
|
||||
{{
|
||||
"match_score": 0-10,
|
||||
"reason": "简要说明匹配情况"
|
||||
}}
|
||||
"""
|
||||
|
||||
请仅返回 JSON 格式(不要其他文字):
|
||||
{{"match_score": 0-10, "reason": "简要说明匹配情况"}}"""
|
||||
|
||||
full_prompt = _build_multimodal_prompt_with_frames(text_instruction, frames_b64)
|
||||
|
||||
try:
|
||||
# 调用 LLM 多模态(这里使用占位实现,实际应调用 vision_analyze 或类似工具)
|
||||
# 暂时返回中性分数
|
||||
# TODO: 集成实际的 LLM 多模态调用
|
||||
|
||||
# 模拟 LLM 返回
|
||||
return 0.8, "语义一致性评估(待集成 LLM 多模态)"
|
||||
|
||||
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"语义一致性评估失败: {e}")
|
||||
return 0.5, f"语义评估异常: {e}"
|
||||
logger.warning(f"语义一致性 LLM 评估失败: {e}")
|
||||
return 0.5, f"语义一致性评估异常: {e}"
|
||||
|
||||
|
||||
async def evaluate_character_consistency(
|
||||
@ -296,15 +362,59 @@ async def evaluate_character_consistency(
|
||||
if not frames:
|
||||
return 0.5, "无法提取视频帧,跳过角色一致性评估"
|
||||
|
||||
# 将参考图编码为 base64
|
||||
try:
|
||||
# 调用 LLM 多模态比较
|
||||
# TODO: 集成实际的 LLM 多模态调用
|
||||
|
||||
# 模拟返回
|
||||
return 0.85, "角色一致性评估(待集成 LLM 多模态)"
|
||||
|
||||
ref_b64 = _encode_image_to_base64(reference_image)
|
||||
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}"
|
||||
|
||||
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user