football_replay_match_live.py
12.8 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
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