Showing
6 changed files
with
198 additions
and
10 deletions
| 1 | +input_kafka: | ||
| 2 | + servers: prod-kafka:9092 | ||
| 3 | + group_id: ai_match_service_prod | ||
| 4 | + topic: football_replay_match_req | ||
| 5 | + # username: xxx | ||
| 6 | + # password: xxx | ||
| 7 | + | ||
| 8 | +output_kafka: | ||
| 9 | + servers: prod-kafka:9092 | ||
| 10 | + topic: football_replay_match_resp | ||
| 11 | + | ||
| 12 | +cache_dir: /data/cache | ||
| 13 | +llm_base_url: http://prod-llm:11434 | ||
| 14 | +llm_model: qwen3.6:35b-a3b-q8_0 | ||
| 15 | +llm_temperature: 0.7 |
| 1 | -app_name: "Football Replay Service" | ||
| 2 | -app_version: "1.0.0" | 1 | +input_kafka: |
| 2 | + servers: 192.168.0.14:9092 | ||
| 3 | + group_id: ai_match_service | ||
| 4 | + topic: football_replay_match_req | ||
| 5 | + # username: xxx | ||
| 6 | + # password: xxx | ||
| 7 | + | ||
| 8 | +output_kafka: | ||
| 9 | + servers: 192.168.0.14:9092 | ||
| 10 | + topic: football_replay_match_resp | ||
| 11 | + | ||
| 12 | + | ||
| 13 | +llm: | ||
| 14 | + base_url: http://192.168.1.59:11434 | ||
| 15 | + model_name: qwen3.6:35b-a3b-q8_0 | ||
| 16 | + temperature: 0.7 | ||
| 17 | + | ||
| 18 | +common: | ||
| 19 | + cache_dir: ./cache |
| 1 | -from aabd.base.cfg_loader import load_yaml_by_file_with_env | ||
| 2 | -from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars | 1 | +import os |
| 3 | from pathlib import Path | 2 | from pathlib import Path |
| 4 | from logging import getLogger | 3 | from logging import getLogger |
| 4 | + | ||
| 5 | +from aabd.base.cfg_loader import load_yaml_by_file_with_env | ||
| 6 | +from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars | ||
| 7 | + | ||
| 5 | logger = getLogger(__name__) | 8 | logger = getLogger(__name__) |
| 6 | cfg_dir = Path(__file__).parent.absolute() / 'cfg' | 9 | cfg_dir = Path(__file__).parent.absolute() / 'cfg' |
| 7 | -settings = EnhanceDict(load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix())) | 10 | + |
| 11 | +# 加载基础配置 | ||
| 12 | +base_config = load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix()) | ||
| 13 | + | ||
| 14 | +# 根据 ENV 环境变量加载环境特定配置并合并(如 config.dev.yaml / config.prod.yaml) | ||
| 15 | +env = os.getenv('ENV', 'dev') | ||
| 16 | +env_file = cfg_dir / f'config.{env}.yaml' | ||
| 17 | +if env_file.exists(): | ||
| 18 | + env_config = load_yaml_by_file_with_env(env_file.as_posix()) | ||
| 19 | + if isinstance(base_config, dict) and isinstance(env_config, dict): | ||
| 20 | + for key, value in env_config.items(): | ||
| 21 | + if key in base_config and isinstance(base_config[key], dict) and isinstance(value, dict): | ||
| 22 | + base_config[key].update(value) | ||
| 23 | + else: | ||
| 24 | + base_config[key] = value | ||
| 25 | + logger.info(f"已加载环境配置: {env_file.name}") | ||
| 26 | + | ||
| 27 | +settings = EnhanceDict(base_config) | ||
| 8 | settings.update(read_prefixed_env_vars('FRM_')) | 28 | settings.update(read_prefixed_env_vars('FRM_')) |
| 9 | 29 | ||
| 10 | logger.info(f'Settings: {settings}') | 30 | logger.info(f'Settings: {settings}') |
| 1 | -def replay_match_event(data): | ||
| 2 | - pass | ||
| 1 | +import json | ||
| 2 | +import os | ||
| 3 | +from pathlib import Path | ||
| 4 | + | ||
| 5 | +try: | ||
| 6 | + from .llm_image import Video2Frame, frames2content | ||
| 7 | + from ..util.football_replay_match_live import FootballReplayMatchLive | ||
| 8 | + from config import settings | ||
| 9 | +except ImportError: | ||
| 10 | + import sys | ||
| 11 | + sys.path.insert(0, str(Path(__file__).parent.parent)) | ||
| 12 | + from core.llm_image import Video2Frame, frames2content | ||
| 13 | + from util.football_replay_match_live import FootballReplayMatchLive | ||
| 14 | + from config import settings | ||
| 15 | + | ||
| 16 | + | ||
| 17 | +# 从配置中读取参数(配置项需在 config.yaml 或对应环境配置中定义) | ||
| 18 | +_cache_dir = settings.common.get('cache_dir', None) | ||
| 19 | +_llm_base_url = settings.llm.get('base_url', None) | ||
| 20 | +_llm_model = settings.llm.get('model_name', None) | ||
| 21 | +_llm_temperature = settings.llm.get('temperature', None) | ||
| 22 | + | ||
| 23 | +# 初始化视频处理与匹配器 | ||
| 24 | +v2f = Video2Frame(_cache_dir) | ||
| 25 | +matcher = FootballReplayMatchLive( | ||
| 26 | + base_url=_llm_base_url, | ||
| 27 | + model=_llm_model, | ||
| 28 | + temperature=_llm_temperature, | ||
| 29 | +) | ||
| 30 | + | ||
| 31 | + | ||
| 32 | +def replay_match_event(data: dict) -> dict: | ||
| 33 | + """ | ||
| 34 | + 足球回看与直播事件匹配接口。 | ||
| 35 | + | ||
| 36 | + 请求参数: | ||
| 37 | + id : str 任务唯一键 | ||
| 38 | + match_id : str 比赛id | ||
| 39 | + replay : dict 回看信息 | ||
| 40 | + |- id : str 回看id | ||
| 41 | + |- url : str 回看url(m3u8) | ||
| 42 | + |- start_utc : int 回看开始utc时间 | ||
| 43 | + |- end_utc : int 回看结束utc时间 | ||
| 44 | + events : list 赛事事件片段(直播候选) | ||
| 45 | + |- id : str 事件id | ||
| 46 | + |- type : str 事件类型(1:进球) | ||
| 47 | + |- url : str 事件片段url(m3u8) | ||
| 48 | + |- event_utc : int 事件发生的utc时间点 | ||
| 49 | + | ||
| 50 | + 响应参数: | ||
| 51 | + id : str 任务唯一键 | ||
| 52 | + match_id : str 比赛id | ||
| 53 | + replay_id : str 回看id | ||
| 54 | + event_id : str|null 匹配到的比赛片段id,null表示无匹配 | ||
| 55 | + """ | ||
| 56 | + task_id = data.get("id") | ||
| 57 | + match_id = data.get("match_id") | ||
| 58 | + replay = data.get("replay", {}) | ||
| 59 | + events = data.get("events", []) | ||
| 60 | + | ||
| 61 | + replay_id = replay.get("id") | ||
| 62 | + replay_url = replay.get("url") | ||
| 63 | + replay_start = replay.get("start_utc") | ||
| 64 | + replay_end = replay.get("end_utc") | ||
| 65 | + | ||
| 66 | + if not replay_url or replay_start is None or replay_end is None: | ||
| 67 | + raise ValueError("replay 参数不完整,需要 url、start_utc、end_utc") | ||
| 68 | + | ||
| 69 | + # 1. 为回看视频提取帧并构建 LLM content | ||
| 70 | + try: | ||
| 71 | + replay_frames = v2f.to_frames( | ||
| 72 | + url=replay_url, | ||
| 73 | + start_utc=replay_start, | ||
| 74 | + end_utc=replay_end, | ||
| 75 | + fps=2, | ||
| 76 | + ) | ||
| 77 | + replay_contents = frames2content(replay_frames) | ||
| 78 | + # 保持与 football_replay_match_live.py 中一致的提示结构 | ||
| 79 | + replay_contents.insert(0, {"type": "text", "text": "\n【回放片段信息】\n"}) | ||
| 80 | + replay_contents.append({"type": "text", "text": "\n回放解说内容:无\n"}) | ||
| 81 | + except Exception as e: | ||
| 82 | + raise RuntimeError(f"回看视频处理失败: {e}") | ||
| 83 | + | ||
| 84 | + # 2. 为每个 event(直播候选)提取帧并构建 LLM content | ||
| 85 | + # event 的 url 本身已是视频片段,直接处理完整片段 | ||
| 86 | + live_videos = [] | ||
| 87 | + for event in events: | ||
| 88 | + event_id = event.get("id") | ||
| 89 | + event_url = event.get("url") | ||
| 90 | + | ||
| 91 | + if not event_url: | ||
| 92 | + continue | ||
| 93 | + | ||
| 94 | + try: | ||
| 95 | + event_frames = v2f.to_frames( | ||
| 96 | + url=event_url, | ||
| 97 | + start_utc=0, | ||
| 98 | + end_utc=999999, # 传极大值,实际以视频真实长度为准 | ||
| 99 | + fps=2, | ||
| 100 | + ) | ||
| 101 | + event_contents = frames2content(event_frames) | ||
| 102 | + event_contents.insert( | ||
| 103 | + 0, {"type": "text", "text": f"### 候选片段 video_id: {event_id} ###"} | ||
| 104 | + ) | ||
| 105 | + event_contents.append({"type": "text", "text": "\n该片段解说内容: 无\n"}) | ||
| 106 | + except Exception: | ||
| 107 | + # 单个 event 处理失败不影响整体流程 | ||
| 108 | + continue | ||
| 109 | + | ||
| 110 | + live_videos.append({ | ||
| 111 | + "video_id": event_id, | ||
| 112 | + "video_path": "", # 已提供 llm_contents,无需本地路径 | ||
| 113 | + "asr_text": "", | ||
| 114 | + "llm_contents": event_contents, | ||
| 115 | + }) | ||
| 116 | + | ||
| 117 | + # 3. 执行匹配 | ||
| 118 | + if len(live_videos) == 0: | ||
| 119 | + matched_event_id = None | ||
| 120 | + elif len(live_videos) == 1: | ||
| 121 | + # 只有一个候选片段,直接视为匹配结果 | ||
| 122 | + matched_event_id = live_videos[0]["video_id"] | ||
| 123 | + else: | ||
| 124 | + try: | ||
| 125 | + result = matcher._match_batch(replay_contents, live_videos, max_parallel=3) | ||
| 126 | + matched_event_id = result.get("video_id") if result else None | ||
| 127 | + except Exception: | ||
| 128 | + matched_event_id = None | ||
| 129 | + | ||
| 130 | + return { | ||
| 131 | + "id": task_id, | ||
| 132 | + "match_id": match_id, | ||
| 133 | + "replay_id": replay_id, | ||
| 134 | + "event_id": matched_event_id, | ||
| 135 | + } |
| @@ -55,9 +55,11 @@ def kafka_message_iterator_thread(message_iterator, config, callback_func): | @@ -55,9 +55,11 @@ def kafka_message_iterator_thread(message_iterator, config, callback_func): | ||
| 55 | for message in message_iterator: | 55 | for message in message_iterator: |
| 56 | try: | 56 | try: |
| 57 | json_message = json.loads(message) | 57 | json_message = json.loads(message) |
| 58 | - replay_match_event(json_message) | ||
| 59 | - logger.info(message) | ||
| 60 | - except: | 58 | + result = replay_match_event(json_message) |
| 59 | + task_id = json_message.get("id") | ||
| 60 | + callback_func(task_id, json.dumps(result, ensure_ascii=False)) | ||
| 61 | + logger.info(f"Processed task_id={task_id}, result={result}") | ||
| 62 | + except Exception: | ||
| 61 | logger.exception(f"Error processing message: {message}") | 63 | logger.exception(f"Error processing message: {message}") |
| 62 | 64 | ||
| 63 | 65 |
-
Please register or login to post a comment