From eb10d97aa20a97a981fc68c2fd00d35dbaf184a2 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 25 Jun 2026 21:10:35 +0800 Subject: [PATCH] =?UTF-8?q?feat:=20eval=5Fvideo/eval=5Fmusic=20=E6=8E=A5?= =?UTF-8?q?=E5=85=A5=20LLM=20=E8=AF=84=E4=BC=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - eval_video.py: semantic_consistency + character_consistency 调用 llm_bridge - eval_music.py: emotion_lyrics_match + prompt_adherence 调用 llm_bridge - 异常时回退默认分数,保证产线不中断 --- app/eval_music.py | 91 +++++++++++++++++++++++--- app/eval_video.py | 160 ++++++++++++++++++++++++++++++++++++++-------- 2 files changed, 218 insertions(+), 33 deletions(-) diff --git a/app/eval_music.py b/app/eval_music.py index 86f28fa..d2eb02f 100644 --- a/app/eval_music.py +++ b/app/eval_music.py @@ -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( diff --git a/app/eval_video.py b/app/eval_video.py index bfc5eea..0be4de7 100644 --- a/app/eval_video.py +++ b/app/eval_video.py @@ -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}"