football_replay_match_live.py 12.8 KB
import json
import os.path
from pathlib import Path

from langchain_core.messages import SystemMessage, HumanMessage
from langchain_ollama import ChatOllama

try:
    from .llm_video_content import contents as video_contents
except:
    from llm_video_content import contents as video_contents

req_prompt = """
# Role
你是一名拥有20年经验的足球视频技术分析师,擅长结合**视觉画面**与**解说音频(ASR)**进行跨镜头的事件匹配。你能通过解说员的描述锁定时间背景,并通过视觉特征确认物理细节。

# Task
输入包含:
1. 【回放片段】(Replay):包含视频帧 + **对应的解说文本**。
2. 【直播进球片段列表】(Live Candidates):包含视频帧 + **对应的解说文本**。

目标:在【直播片段】中找到与【回放片段】属于**同一个进球事件**的片段。如果没有任何片段匹配,返回 null。

# Analysis Workflow (多模态分析流程)

请按照以下步骤进行推理:

### 第一步:解说词元数据提取 (听觉线索)
分析【回放片段】的解说文本,寻找以下关键信息:
- **时间指代**:解说员是否提到了具体时间?(如“上半场”、“第10分钟”、“开场不久”)。
- **事件描述**:解说员如何描述这个进球?(如“世界波”、“点球”、“补射”、“乌龙球”)。
- **球员提及**:解说员念出了谁的名字?

### 第二步:视觉物理指纹提取 (视觉线索)
忽略镜头语言(慢放、特写),提取核心物理特征:
- **进攻与射门**:进攻方式(传中/直塞)、射门部位(头/脚)、射门位置。
- **球路与防守**:球的轨迹(高/低/折射)、门将扑救动作(侧扑/倒地/未动)。
- **庆祝**:进球后的庆祝动作(仅作为辅助验证)。

### 第三步:跨模态匹配与验证
遍历【直播片段】,结合视觉和听觉进行判断:
- **视觉一致性**:直播片段的动作、轨迹、门将反应是否与回放完全吻合?
- **听觉一致性**:
    - 如果回放解说提到“这是第5分钟的进球”,而直播片段的时间戳是90分钟,**不要直接排除**,而是检查直播片段的解说是否也提到了“回顾第5分钟”或者直播片段的视觉内容确实是第5分钟的动作。
    - **核心原则**:视觉物理特征 > 解说词描述 > 时间戳元数据。
    - **特殊情况**:如果视觉特征高度相似(如同一个球员、同一个角度),但解说词明确说“这是另一个进球(例如:这是他的第二个进球,而回放说是第一个)”,则判定为不匹配。

# Logic Constraints (逻辑约束 - 必须严格遵守)

- **时间单向性原则(铁律)**:
    - **回看中的比赛时间一定在比赛片段画面时间之后**。
    - 逻辑:回放是对过去发生事件的回顾。如果【回放片段】中解说提到的比赛时间(或画面显示的比赛时钟)**早于**【直播片段】中解说提到的比赛时间(或画面时钟),则**绝对不可能**是同一个事件。
    - *示例*:回放解说在描述“第10分钟的进球”,而直播片段明确发生在“第5分钟”,则该直播片段**一定不是**目标。
- **时间陷阱**:回放可能是赛后集锦。如果解说员说“让我们看看**刚才**那个球”或者“**上半场**那个球”,即使当前比赛时间是90分钟,也要去匹配对应时间段的直播片段(或视觉特征)。
- **同名陷阱**:如果解说提到“又是**凯恩**进球了”,不能只看凯恩,必须看**怎么进的**(头球还是点球)。
- **无匹配处理**:如果所有候选片段在视觉动作(如射门方式、进球位置)或关键事件逻辑上与回放明显不符,必须判定为无匹配,将 `video_id` 设为 `null`。

### 输出要求

请仅输出一个JSON格式的结果,不要输出任何分析过程。不要包含 markdown 标记(如 ```json ... ```),不要包含任何解释或额外文本。
格式如下:
{
  "replay_summary": {
    "audio_cues": "解说提到的关键信息(如:'第15分钟', '远射', '德布劳内')",
    "visual_cues": "视觉关键特征(如:'禁区外右脚', '球挂死角', '门将飞身扑救')"
  },
  "reasoning": "综合分析:回放解说提到是'上半场的远射',视觉显示'17号球员禁区外起脚'。Candidate_1 视觉是'近距离推射',排除。Candidate_2 视觉是'禁区外远射',且门将动作一致,虽然直播时间显示是下半场(可能是集锦回顾),但解说也提到了'回顾上半场',确认为同一事件。",
  "video_id": "Candidate_2"
}"""


class FootballReplayMatchLive:
    def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key'):
        self.base_url = base_url
        self.model = model
        self.temperature = temperature
        self.api_key = api_key
        # self.model = ChatOllama(base_url="http://192.168.1.59:11434", model="qwen3.6:35b-a3b-q8_0", temperature=0.7,
        #                    keep_alive=-1, reasoning=False)
        # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7,
        #                        api_key='no_key')
        self.model = ChatOllama(base_url=base_url, model=model, temperature=temperature, keep_alive=-1, reasoning=False)

    def _match_once(self, replay_video_contents: list, live_videos: list[dict], cache_path=None, record: list = None):

        if len(live_videos) == 0:
            return None
        elif len(live_videos) == 1:
            live_video_path = live_videos[0].get("video_path", None)
            live_video_id = live_videos[0].get("video_id", os.path.basename(live_video_path))
            asr_text = live_videos[0].get("asr_text", '')
            live = {
                "video_id": live_video_id,
                "video_path": live_video_path,
                "asr_text": asr_text,
            }
            if record is not None:
                record.append({"live": live, "llm_result": None, 'live_list': [live]})
            return live
        user_contents = []
        # replay_video_contents = video_contents(replay_video["video_path"], "\n【回放片段信息】\n",
        #                                        prompt_end=f"\n回放解说内容:{replay_video['asr_text']}\n",
        #                                        video_name=os.path.basename(replay_video["video_path"]),
        #                                        fps=2, max_frames=999, sampling_mode="head", max_short_edge=480)
        live_videos_contents = []
        live_map = {}
        live_records = {}
        for live_video in live_videos:
            live_video_path = live_video.get("video_path", None)
            live_video_id = live_video.get("video_id", os.path.basename(live_video_path))
            asr_text = live_video.get("asr_text", '')
            live_video_contents = live_video.get("llm_contents", None)
            if live_video_contents is None:
                live_video_contents = video_contents(live_video_path,
                                                     prompt_start=f"### 候选片段 video_id: {live_video_id} ###",
                                                     prompt_end=f"\n该片段解说内容: {asr_text}\n",
                                                     video_name=os.path.basename(live_video_path),
                                                     fps=2, max_frames=999, sampling_mode="head", max_short_edge=480)
            live_map[live_video_id] = {
                "video_id": live_video_id,
                "video_path": live_video_path,
                "asr_text": asr_text,
                "contents": live_video_contents,

            }
            live_records[live_video_id] = {
                "video_id": live_video_id,
                "video_path": live_video_path,
                "asr_text": asr_text,
            }
            live_videos_contents.extend(live_video_contents)

        user_contents.extend(replay_video_contents)
        user_contents.append({'type': "text", "text": "\n【候选直播片段列表】\n"})
        user_contents.extend(live_videos_contents)
        user_contents.append({'type': "text", "text": "\n请根据上述片段进行匹配并按要求输出结果\n"})
        system_message = SystemMessage(content=req_prompt)
        user_message = HumanMessage(content=user_contents)
        result = self.model.invoke([system_message, user_message]).content
        try:
            result_json = json.loads(result)
        except json.JSONDecodeError:
            try:
                result_json = json.loads(result.replace("```json", "").replace("```", ""))
            except Exception as e:
                print("JSON解析失败:", result)
                raise e

        video_id = result_json.get("video_id", None)
        result_live = live_map.get(video_id, None)
        if record is not None:
            record.append(
                {"live": live_records.get(video_id,None), "llm_result": result_json, 'live_list': list(live_records.values())})
        return result_live

    def _match_batch(self, replay_video_contents: list, live_videos: list[dict], max_parallel: int = 3, cache_path=None,
                     record: list = None):
        """
        Match a replay video with live videos to find the most likely match.
        :param max_parallel:
        :param replay_video: Path to the replay video.(video_path,asr_text)
        :param live_videos: [(video_id,video_path,asr_text)]
        :param cache_path: Path to cache the result.
        :return: JSON object containing the match result.
        """
        if cache_path is not None and os.path.exists(cache_path):
            with open(cache_path, 'r', encoding='utf-8') as f:
                return json.loads(f.read())

        # 按照max_parallel对live_videos进行分组
        live_videos_groups = [live_videos[i::max_parallel] for i in range(max_parallel)]
        # 过滤掉空的分组
        live_videos_groups = [g for g in live_videos_groups if g]
        # 如果group 大于1 并且最后一个group 只有一个元素,将其唯一元素放入倒数第二个group,并删除最后一个group
        if len(live_videos_groups) > 1 and len(live_videos_groups[-1]) == 1:
            live_videos_groups[-2].append(live_videos_groups[-1][0])
            live_videos_groups.pop()

        if len(live_videos_groups) > 1:
            match_result = []
            for live_videos_group in live_videos_groups:
                g_live = self._match_once(replay_video_contents, live_videos_group, cache_path, record)
                if g_live is None:
                    continue
                match_result.append(g_live)
            if len(match_result) == 0:
                return None
            else:
                return self._match_batch(replay_video_contents, match_result, max_parallel, cache_path, record)
        elif len(live_videos_groups) == 1:
            return self._match_once(replay_video_contents, live_videos_groups[0], cache_path, record)
        else:
            return None

    def match_batch(self, replay_video: dict, live_videos: list[dict], max_parallel: int = 3, cache_path=None):
        if cache_path is not None and os.path.exists(cache_path):
            try:
                with open(cache_path, 'r', encoding='utf-8') as f:
                    return json.loads(f.read()).get("result", None)
            except:
                os.remove(cache_path)
        replay_video_contents = video_contents(replay_video["video_path"], "\n【回放片段信息】\n",
                                               prompt_end=f"\n回放解说内容:{replay_video['asr_text']}\n",
                                               video_name=os.path.basename(replay_video["video_path"]),
                                               fps=2, max_frames=999, sampling_mode="head", max_short_edge=480)
        live_record = []
        result = self._match_batch(replay_video_contents, live_videos, max_parallel, cache_path, live_record)
        if result is not None:
            result_no_content = {
                "video_id": result.get("video_id", None),
                "video_path": result.get("video_path", None),
                "asr_text": result.get("asr_text", None),
            }
        else:
            result_no_content = None
        record = {
            "request": {
                "replay_video": replay_video,
                "live_videos": live_videos,
                "max_parallel": max_parallel,
                "cache_path": cache_path
            },
            "result": result_no_content,
            "live_record": live_record
        }
        if cache_path is not None:
            os.makedirs(Path(cache_path).parent, exist_ok=True)
            with open(cache_path, 'w', encoding='utf-8') as f:
                f.write(json.dumps(record, ensure_ascii=False, indent=4))
        return result_no_content