Showing
4 changed files
with
232 additions
and
104 deletions
| 1 | import os | 1 | import os |
| 2 | +import time | ||
| 2 | import logging | 3 | import logging |
| 3 | from aabd.base.enhance_dict import value_or_default | 4 | from aabd.base.enhance_dict import value_or_default |
| 4 | 5 | ||
| @@ -25,6 +26,8 @@ class FootballReplayMatch: | @@ -25,6 +26,8 @@ class FootballReplayMatch: | ||
| 25 | def match_by_time(self, replay, events): | 26 | def match_by_time(self, replay, events): |
| 26 | 27 | ||
| 27 | start_utc = replay.get('start_utc') | 28 | start_utc = replay.get('start_utc') |
| 29 | + if start_utc is None: | ||
| 30 | + return None | ||
| 28 | 31 | ||
| 29 | events = [(start_utc - e.get('event_utc'), e) for e in events if e.get('event_utc') is not None] | 32 | events = [(start_utc - e.get('event_utc'), e) for e in events if e.get('event_utc') is not None] |
| 30 | if len(events) == 0: | 33 | if len(events) == 0: |
| @@ -79,30 +82,36 @@ class FootballReplayMatch: | @@ -79,30 +82,36 @@ class FootballReplayMatch: | ||
| 79 | # 该时间段附近是否有进球事件 | 82 | # 该时间段附近是否有进球事件 |
| 80 | replay_info = data['replay'] | 83 | replay_info = data['replay'] |
| 81 | replay_id = replay_info.get('id') | 84 | replay_id = replay_info.get('id') |
| 85 | + replay_start_utc = replay_info.get('start_utc', None) | ||
| 86 | + replay_end_utc = replay_info.get('end_utc', None) | ||
| 82 | live_events = data['events'] | 87 | live_events = data['events'] |
| 83 | goal_live_events = [e for e in live_events if str(e.get('type', '')) == '1'] | 88 | goal_live_events = [e for e in live_events if str(e.get('type', '')) == '1'] |
| 84 | 89 | ||
| 85 | matched_event = self.match_by_time(replay_info, goal_live_events) | 90 | matched_event = self.match_by_time(replay_info, goal_live_events) |
| 86 | # print(f"{task_id}_match_by_time matched_event: {matched_event}") | 91 | # print(f"{task_id}_match_by_time matched_event: {matched_event}") |
| 87 | if matched_event is None: | 92 | if matched_event is None: |
| 93 | + start_time = time.time() | ||
| 88 | replay_det_info = self.det_goal_replay(replay_info, task_id) | 94 | replay_det_info = self.det_goal_replay(replay_info, task_id) |
| 95 | + end_time = time.time() | ||
| 96 | + logger.info(f"判断是否是进球耗时: {end_time - start_time} 秒") | ||
| 97 | + print(f"判断是否是进球耗时: {end_time - start_time} 秒") | ||
| 89 | replay_event_name = replay_det_info.get('event_name', '') | 98 | replay_event_name = replay_det_info.get('event_name', '') |
| 90 | if replay_event_name == '进球': | 99 | if replay_event_name == '进球': |
| 91 | matched_event = self.match_by_llm(replay_info, goal_live_events, task_id) | 100 | matched_event = self.match_by_llm(replay_info, goal_live_events, task_id) |
| 92 | if matched_event is None: | 101 | if matched_event is None: |
| 93 | - logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 是进球但是未能找到匹配的事件") | ||
| 94 | - # print(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 是进球但是未能找到匹配的事件") | 102 | + logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_start_utc}_{replay_end_utc} 是进球但是未能找到匹配的事件") |
| 103 | + print(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_start_utc}_{replay_end_utc} 是进球但是未能找到匹配的事件") | ||
| 95 | else: | 104 | else: |
| 96 | matched_event_id = matched_event.get('video_id') | 105 | matched_event_id = matched_event.get('video_id') |
| 97 | - logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 98 | - # print(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | 106 | + logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_start_utc}_{replay_end_utc} 对应的进球的事件视频是{matched_event_id}") |
| 107 | + print(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_start_utc}_{replay_end_utc} 对应的进球的事件视频是{matched_event_id}") | ||
| 99 | else: | 108 | else: |
| 100 | - logger.info(f"{task_id}_LLM判断{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 不是进球,无需匹配") | ||
| 101 | - # print(f"{task_id}_LLM判断{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 不是进球,无需匹配") | 109 | + logger.info(f"{task_id}_LLM判断{replay_info['url']}_{replay_start_utc}_{replay_end_utc} 不是进球,无需匹配") |
| 110 | + print(f"{task_id}_LLM判断{replay_info['url']}_{replay_start_utc}_{replay_end_utc} 不是进球,无需匹配") | ||
| 102 | else: | 111 | else: |
| 103 | matched_event_id = matched_event.get('id') | 112 | matched_event_id = matched_event.get('id') |
| 104 | - logger.info(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 105 | - # print(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | 113 | + logger.info(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_start_utc}_{replay_end_utc} 对应的进球的事件视频是{matched_event_id}") |
| 114 | + print(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_start_utc}_{replay_end_utc} 对应的进球的事件视频是{matched_event_id}") | ||
| 106 | return { | 115 | return { |
| 107 | "id": task_id, | 116 | "id": task_id, |
| 108 | "match_id": match_id, | 117 | "match_id": match_id, |
| @@ -9,6 +9,7 @@ api_test.py - 对 api.py 中 FootballReplayMatch.replay_match_event 的单元测 | @@ -9,6 +9,7 @@ api_test.py - 对 api.py 中 FootballReplayMatch.replay_match_event 的单元测 | ||
| 9 | 5. 无进球事件 / events 为空 | 9 | 5. 无进球事件 / events 为空 |
| 10 | 6. 边界:缺少 replay 键时抛出异常 | 10 | 6. 边界:缺少 replay 键时抛出异常 |
| 11 | """ | 11 | """ |
| 12 | +import os | ||
| 12 | import time | 13 | import time |
| 13 | import unittest | 14 | import unittest |
| 14 | from types import SimpleNamespace | 15 | from types import SimpleNamespace |
| @@ -19,47 +20,65 @@ try: | @@ -19,47 +20,65 @@ try: | ||
| 19 | except ImportError: | 20 | except ImportError: |
| 20 | from .api import FootballReplayMatch | 21 | from .api import FootballReplayMatch |
| 21 | 22 | ||
| 23 | +from pathlib import Path | ||
| 24 | +def get_video_files_pathlib(folder_path, extensions=None): | ||
| 25 | + if extensions is None: | ||
| 26 | + extensions = {'.mp4'} | ||
| 27 | + else: | ||
| 28 | + extensions = {ext.lower() for ext in extensions} | ||
| 29 | + | ||
| 30 | + folder = Path(folder_path) | ||
| 31 | + image_files = [f.name for f in folder.iterdir() | ||
| 32 | + if f.is_file() and f.suffix.lower() in extensions] | ||
| 33 | + return sorted(image_files) | ||
| 22 | 34 | ||
| 23 | def _fake_settings(match_by_time_threshold: int = 30): | 35 | def _fake_settings(match_by_time_threshold: int = 30): |
| 24 | """构造一个 settings 伪对象,兼容 api.py 中的 value_or_default 读取逻辑。""" | 36 | """构造一个 settings 伪对象,兼容 api.py 中的 value_or_default 读取逻辑。""" |
| 25 | return SimpleNamespace( | 37 | return SimpleNamespace( |
| 26 | match_by_time_threshold=match_by_time_threshold, | 38 | match_by_time_threshold=match_by_time_threshold, |
| 27 | - llm=SimpleNamespace(base_url="http://192.168.1.59:11434/v1", model_name="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7), | ||
| 28 | - common=SimpleNamespace(cache_dir="/root/lzw/tmp_0526_replay_cache0526/videos_cache", task_log_dir="/root/lzw/tmp_0526_replay_cache0526/tasks_log"), | 39 | + llm=SimpleNamespace(base_url="http://192.168.1.59:11434/v1", |
| 40 | + model_name="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 41 | + temperature=0.7, | ||
| 42 | + api_key='no_key' | ||
| 43 | + ), | ||
| 44 | + common=SimpleNamespace(cache_dir="/root/lzw/tmp_0527_replay_cache_08/videos_cache", | ||
| 45 | + task_log_dir="/root/lzw/tmp_0527_replay_cache_08/tasks_log" | ||
| 46 | + ), | ||
| 29 | save_frames_enable=True, | 47 | save_frames_enable=True, |
| 30 | ) | 48 | ) |
| 31 | 49 | ||
| 32 | 50 | ||
| 33 | -def _base_data(): | ||
| 34 | - """返回一份基础伪数据,replay.start_utc = 1000(毫秒)。""" | ||
| 35 | 51 | ||
| 52 | + | ||
| 53 | +def get_event_list(): | ||
| 36 | # url = "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8" | 54 | # url = "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8" |
| 55 | + events_mp4_dir = r"/root/lzw/69e4ed7c872d573c9554510b-auto-10154973/goal" | ||
| 56 | + | ||
| 57 | + events_mp4_names = get_video_files_pathlib(events_mp4_dir) | ||
| 58 | + | ||
| 59 | + event_lis = [] | ||
| 60 | + for mp4 in events_mp4_names: | ||
| 61 | + mp4_path = os.path.join(events_mp4_dir, mp4) | ||
| 62 | + event_lis.append({ | ||
| 63 | + "id": f"event_{mp4.split('.')[0]}", | ||
| 64 | + "type": "1", | ||
| 65 | + "url": mp4_path, | ||
| 66 | + # "event_utc": None | ||
| 67 | + }) | ||
| 68 | + return event_lis | ||
| 69 | + | ||
| 70 | +def _base_data(i, replay_path, event_lis): | ||
| 71 | + | ||
| 37 | return { | 72 | return { |
| 38 | - "id": "task_001", | ||
| 39 | - "match_id": "match_001", | 73 | + "id": f"task_{str(i).zfill(3)}", |
| 74 | + "match_id": f"match_{str(i).zfill(3)}", | ||
| 40 | "replay": { | 75 | "replay": { |
| 41 | - "id": "replay_001", | ||
| 42 | - "url": "/root/lzw/finished/69dd5845dd0412067b8d5587-auto-1776074760653/replays/videos/00_14_32_080-00_15_10_560.mp4", | ||
| 43 | - "start_utc": 5_000, | ||
| 44 | - "end_utc": 21_000 | ||
| 45 | - }, | ||
| 46 | - "events": [ | ||
| 47 | - {"id": "event_001", | ||
| 48 | - "type": "1", | ||
| 49 | - "url": "/root/lzw/finished/69dd5845dd0412067b8d5587-auto-1776074760653/live/videos/00-14-09-052.mp4", | ||
| 50 | - "event_utc": None | ||
| 51 | - }, | ||
| 52 | - {"id": "event_002", | ||
| 53 | - "type": "1", | ||
| 54 | - "url": "/root/lzw/finished/69dd5845dd0412067b8d5587-auto-1776074760653/live/videos/00-38-40-052.mp4", | ||
| 55 | - "event_utc": None | 76 | + "id": f"replay_{str(i).zfill(3)}", |
| 77 | + "url": replay_path, | ||
| 78 | + # "start_utc": None, | ||
| 79 | + # "end_utc": None | ||
| 56 | }, | 80 | }, |
| 57 | - {"id": "event_003", | ||
| 58 | - "type": "1", | ||
| 59 | - "url": "/root/lzw/finished/69dd5845dd0412067b8d5587-auto-1776074760653/live/videos/01-36-04-972.mp4", | ||
| 60 | - "event_utc": None | ||
| 61 | - } | ||
| 62 | - ], | 81 | + "events": event_lis, |
| 63 | } | 82 | } |
| 64 | 83 | ||
| 65 | 84 | ||
| @@ -68,18 +87,39 @@ class TestReplayMatchEvent(unittest.TestCase): | @@ -68,18 +87,39 @@ class TestReplayMatchEvent(unittest.TestCase): | ||
| 68 | @patch("utils.football_replay_match_live.FootballReplayMatchLive") | 87 | @patch("utils.football_replay_match_live.FootballReplayMatchLive") |
| 69 | @patch("utils.football_replay_video_event_by_llm.FootballReplayVideoEvent") | 88 | @patch("utils.football_replay_video_event_by_llm.FootballReplayVideoEvent") |
| 70 | def test_match_by_time_success(self, mock_video_cls, mock_live_cls): | 89 | def test_match_by_time_success(self, mock_video_cls, mock_live_cls): |
| 71 | - """场景1:时间差在阈值内,直接通过 match_by_time 命中。""" | 90 | + # """场景1:时间差在阈值内,直接通过 match_by_time 命中。""" |
| 72 | settings = _fake_settings(match_by_time_threshold=10) # threshold = 30 * 1000 = 30000 ms | 91 | settings = _fake_settings(match_by_time_threshold=10) # threshold = 30 * 1000 = 30000 ms |
| 73 | frm = FootballReplayMatch(settings) | 92 | frm = FootballReplayMatch(settings) |
| 74 | 93 | ||
| 75 | - data = _base_data() | 94 | + replays_mp4_dir = "/root/lzw/69e4ed7c872d573c9554510b-auto-10154973/replay" |
| 95 | + relays_mp4_names = get_video_files_pathlib(replays_mp4_dir) | ||
| 96 | + | ||
| 97 | + event_list = get_event_list() | ||
| 98 | + | ||
| 99 | + all_time = 0.0 | ||
| 100 | + max_time = 0.0 | ||
| 101 | + for i, replay_name in enumerate(relays_mp4_names): | ||
| 102 | + print(100*"*" + f"replay_name: {replay_name}") | ||
| 103 | + replay_path = os.path.join(replays_mp4_dir, replay_name) | ||
| 104 | + data = _base_data(i, replay_path, event_list) | ||
| 76 | 105 | ||
| 77 | start_time = time.time() | 106 | start_time = time.time() |
| 78 | result = frm.replay_match_event(data) | 107 | result = frm.replay_match_event(data) |
| 79 | end_time = time.time() | 108 | end_time = time.time() |
| 80 | - print(f"cost time:{end_time - start_time}") | 109 | + cost_time = end_time - start_time |
| 110 | + all_time += cost_time | ||
| 111 | + print(f"replay_name: {replay_name}, cost time:{cost_time}") | ||
| 112 | + if max_time < cost_time: | ||
| 113 | + max_time = cost_time | ||
| 81 | 114 | ||
| 82 | print(result) | 115 | print(result) |
| 116 | + print() | ||
| 117 | + print() | ||
| 118 | + print() | ||
| 119 | + | ||
| 120 | + print(100*"#") | ||
| 121 | + print(f"max_time: {max_time}") | ||
| 122 | + print(f"all_time: {all_time}") | ||
| 83 | # exit() | 123 | # exit() |
| 84 | 124 | ||
| 85 | # self.assertEqual(result["id"], "task_001") | 125 | # self.assertEqual(result["id"], "task_001") |
| 1 | +import re | ||
| 1 | import json | 2 | import json |
| 2 | import os.path | 3 | import os.path |
| 3 | from pathlib import Path | 4 | from pathlib import Path |
| @@ -11,64 +12,103 @@ try: | @@ -11,64 +12,103 @@ try: | ||
| 11 | except: | 12 | except: |
| 12 | from llm_image import Video2Frame | 13 | from llm_image import Video2Frame |
| 13 | 14 | ||
| 15 | +# req_prompt = """ | ||
| 16 | +# # Role | ||
| 17 | +# 你是一名拥有20年经验的足球视频技术分析师,擅长结合**视觉画面**与**解说音频(ASR)**进行跨镜头的事件匹配。你能通过解说员的描述锁定时间背景,并通过视觉特征确认物理细节。 | ||
| 18 | + | ||
| 19 | +# # Task | ||
| 20 | +# 输入包含: | ||
| 21 | +# 1. 【回放片段】(Replay):包含视频帧 + **对应的解说文本**。 | ||
| 22 | +# 2. 【直播进球片段列表】(Live Candidates):包含视频帧 + **对应的解说文本**。 | ||
| 23 | + | ||
| 24 | +# 目标:在【直播片段】中找到与【回放片段】属于**同一个进球事件**的片段。如果没有任何片段匹配,返回 null。 | ||
| 25 | + | ||
| 26 | +# # Analysis Workflow (多模态分析流程) | ||
| 27 | + | ||
| 28 | +# 请按照以下步骤进行推理: | ||
| 29 | + | ||
| 30 | +# ### 第一步:解说词元数据提取 (听觉线索) | ||
| 31 | +# 分析【回放片段】的解说文本,寻找以下关键信息: | ||
| 32 | +# - **时间指代**:解说员是否提到了具体时间?(如“上半场”、“第10分钟”、“开场不久”)。 | ||
| 33 | +# - **事件描述**:解说员如何描述这个进球?(如“世界波”、“点球”、“补射”、“乌龙球”)。 | ||
| 34 | +# - **球员提及**:解说员念出了谁的名字? | ||
| 35 | + | ||
| 36 | +# ### 第二步:视觉物理指纹提取 (视觉线索) | ||
| 37 | +# 忽略镜头语言(慢放、特写),提取核心物理特征: | ||
| 38 | +# - **进攻与射门**:进攻方式(传中/直塞)、射门部位(头/脚)、射门位置。 | ||
| 39 | +# - **球路与防守**:球的轨迹(高/低/折射)、门将扑救动作(侧扑/倒地/未动)。 | ||
| 40 | +# - **庆祝**:进球后的庆祝动作(仅作为辅助验证)。 | ||
| 41 | + | ||
| 42 | +# ### 第三步:跨模态匹配与验证 | ||
| 43 | +# 遍历【直播片段】,结合视觉和听觉进行判断: | ||
| 44 | +# - **视觉一致性**:直播片段的动作、轨迹、门将反应是否与回放完全吻合? | ||
| 45 | +# - **听觉一致性**: | ||
| 46 | +# - 如果回放解说提到“这是第5分钟的进球”,而直播片段的时间戳是90分钟,**不要直接排除**,而是检查直播片段的解说是否也提到了“回顾第5分钟”或者直播片段的视觉内容确实是第5分钟的动作。 | ||
| 47 | +# - **核心原则**:视觉物理特征 > 解说词描述 > 时间戳元数据。 | ||
| 48 | +# - **特殊情况**:如果视觉特征高度相似(如同一个球员、同一个角度),但解说词明确说“这是另一个进球(例如:这是他的第二个进球,而回放说是第一个)”,则判定为不匹配。 | ||
| 49 | + | ||
| 50 | +# # Logic Constraints (逻辑约束 - 必须严格遵守) | ||
| 51 | + | ||
| 52 | +# - **时间单向性原则(铁律)**: | ||
| 53 | +# - **回看中的比赛时间一定在比赛片段画面时间之后**。 | ||
| 54 | +# - 逻辑:回放是对过去发生事件的回顾。如果【回放片段】中解说提到的比赛时间(或画面显示的比赛时钟)**早于**【直播片段】中解说提到的比赛时间(或画面时钟),则**绝对不可能**是同一个事件。 | ||
| 55 | +# - *示例*:回放解说在描述“第10分钟的进球”,而直播片段明确发生在“第5分钟”,则该直播片段**一定不是**目标。 | ||
| 56 | +# - **时间陷阱**:回放可能是赛后集锦。如果解说员说“让我们看看**刚才**那个球”或者“**上半场**那个球”,即使当前比赛时间是90分钟,也要去匹配对应时间段的直播片段(或视觉特征)。 | ||
| 57 | +# - **同名陷阱**:如果解说提到“又是**凯恩**进球了”,不能只看凯恩,必须看**怎么进的**(头球还是点球)。 | ||
| 58 | +# - **无匹配处理**:如果所有候选片段在视觉动作(如射门方式、进球位置)或关键事件逻辑上与回放明显不符,必须判定为无匹配,将 `video_id` 设为 `null`。 | ||
| 59 | + | ||
| 60 | +# ### 输出要求 | ||
| 61 | + | ||
| 62 | +# 请仅输出一个JSON格式的结果,不要输出任何分析过程。不要包含 markdown 标记(如 ```json ... ```),不要包含任何解释或额外文本。 | ||
| 63 | +# 格式如下: | ||
| 64 | +# { | ||
| 65 | +# "replay_summary": { | ||
| 66 | +# "audio_cues": "解说提到的关键信息(如:'第15分钟', '远射', '德布劳内')", | ||
| 67 | +# "visual_cues": "视觉关键特征(如:'禁区外右脚', '球挂死角', '门将飞身扑救')" | ||
| 68 | +# }, | ||
| 69 | +# "reasoning": "综合分析:回放解说提到是'上半场的远射',视觉显示'17号球员禁区外起脚'。Candidate_1 视觉是'近距离推射',排除。Candidate_2 视觉是'禁区外远射',且门将动作一致,虽然直播时间显示是下半场(可能是集锦回顾),但解说也提到了'回顾上半场',确认为同一事件。", | ||
| 70 | +# "video_id": "Candidate_2" | ||
| 71 | +# }""" | ||
| 72 | + | ||
| 14 | req_prompt = """ | 73 | req_prompt = """ |
| 15 | # Role | 74 | # Role |
| 16 | -你是一名拥有20年经验的足球视频技术分析师,擅长结合**视觉画面**与**解说音频(ASR)**进行跨镜头的事件匹配。你能通过解说员的描述锁定时间背景,并通过视觉特征确认物理细节。 | 75 | +你是一名拥有20年经验的足球视频技术分析师,擅长通过**纯视觉画面分析**进行跨镜头的事件匹配。你能精准捕捉球员动作、球衣号码、比赛时钟及比分牌等视觉细节。 |
| 17 | 76 | ||
| 18 | # Task | 77 | # Task |
| 19 | 输入包含: | 78 | 输入包含: |
| 20 | -1. 【回放片段】(Replay):包含视频帧 + **对应的解说文本**。 | ||
| 21 | -2. 【直播进球片段列表】(Live Candidates):包含视频帧 + **对应的解说文本**。 | 79 | +1. 【回放片段】(Replay):包含视频帧画面。 |
| 80 | +2. 【直播进球片段列表】(Live Candidates):包含视频帧画面。 | ||
| 22 | 81 | ||
| 23 | 目标:在【直播片段】中找到与【回放片段】属于**同一个进球事件**的片段。如果没有任何片段匹配,返回 null。 | 82 | 目标:在【直播片段】中找到与【回放片段】属于**同一个进球事件**的片段。如果没有任何片段匹配,返回 null。 |
| 24 | 83 | ||
| 25 | -# Analysis Workflow (多模态分析流程) | ||
| 26 | - | ||
| 27 | -请按照以下步骤进行推理: | ||
| 28 | - | ||
| 29 | -### 第一步:解说词元数据提取 (听觉线索) | ||
| 30 | -分析【回放片段】的解说文本,寻找以下关键信息: | ||
| 31 | -- **时间指代**:解说员是否提到了具体时间?(如“上半场”、“第10分钟”、“开场不久”)。 | ||
| 32 | -- **事件描述**:解说员如何描述这个进球?(如“世界波”、“点球”、“补射”、“乌龙球”)。 | ||
| 33 | -- **球员提及**:解说员念出了谁的名字? | 84 | +# Visual Analysis Rules (纯视觉匹配规则) |
| 34 | 85 | ||
| 35 | -### 第二步:视觉物理指纹提取 (视觉线索) | ||
| 36 | -忽略镜头语言(慢放、特写),提取核心物理特征: | ||
| 37 | -- **进攻与射门**:进攻方式(传中/直塞)、射门部位(头/脚)、射门位置。 | ||
| 38 | -- **球路与防守**:球的轨迹(高/低/折射)、门将扑救动作(侧扑/倒地/未动)。 | ||
| 39 | -- **庆祝**:进球后的庆祝动作(仅作为辅助验证)。 | 86 | +1. **核心物理特征比对(最高优先级)**: |
| 87 | + - **球员身份**:进攻方球衣颜色、进球者号码、助攻者号码。 | ||
| 88 | + - **进球方式**:射门部位(左脚/右脚/头球)、射门位置(禁区内/禁区外/点球)、球路轨迹(贴地斩/世界波/挑射)。 | ||
| 89 | + - **防守反应**:门将的扑救动作(向左/向右/未动)、后卫的封堵动作。 | ||
| 90 | + - **庆祝动作**:进球后的标志性庆祝(如滑跪、指天、双手比心等)。 | ||
| 91 | + - *判定标准*:上述物理特征必须完全一致,否则视为不同进球。 | ||
| 40 | 92 | ||
| 41 | -### 第三步:跨模态匹配与验证 | ||
| 42 | -遍历【直播片段】,结合视觉和听觉进行判断: | ||
| 43 | -- **视觉一致性**:直播片段的动作、轨迹、门将反应是否与回放完全吻合? | ||
| 44 | -- **听觉一致性**: | ||
| 45 | - - 如果回放解说提到“这是第5分钟的进球”,而直播片段的时间戳是90分钟,**不要直接排除**,而是检查直播片段的解说是否也提到了“回顾第5分钟”或者直播片段的视觉内容确实是第5分钟的动作。 | ||
| 46 | - - **核心原则**:视觉物理特征 > 解说词描述 > 时间戳元数据。 | ||
| 47 | - - **特殊情况**:如果视觉特征高度相似(如同一个球员、同一个角度),但解说词明确说“这是另一个进球(例如:这是他的第二个进球,而回放说是第一个)”,则判定为不匹配。 | 93 | +2. **画面内嵌元数据验证**: |
| 94 | + - **比分牌逻辑**:检查回放画面中的比分(如 1-0)与直播片段进球前后的比分变化是否逻辑自洽。 | ||
| 95 | + - **比赛时钟**:虽然回放可能是赛后集锦(时间戳不准),但如果回放画面明确显示比赛时间(如 18:36),而直播片段发生在完全不同的时间段(如 85:00),且视觉特征不符,则排除。 | ||
| 48 | 96 | ||
| 49 | -# Logic Constraints (逻辑约束 - 必须严格遵守) | 97 | +3. **无匹配处理**: |
| 98 | + - 如果候选片段的进球者号码、射门方式或球衣颜色与回放不一致,必须判定为无匹配。 | ||
| 50 | 99 | ||
| 51 | -- **时间单向性原则(铁律)**: | ||
| 52 | - - **回看中的比赛时间一定在比赛片段画面时间之后**。 | ||
| 53 | - - 逻辑:回放是对过去发生事件的回顾。如果【回放片段】中解说提到的比赛时间(或画面显示的比赛时钟)**早于**【直播片段】中解说提到的比赛时间(或画面时钟),则**绝对不可能**是同一个事件。 | ||
| 54 | - - *示例*:回放解说在描述“第10分钟的进球”,而直播片段明确发生在“第5分钟”,则该直播片段**一定不是**目标。 | ||
| 55 | -- **时间陷阱**:回放可能是赛后集锦。如果解说员说“让我们看看**刚才**那个球”或者“**上半场**那个球”,即使当前比赛时间是90分钟,也要去匹配对应时间段的直播片段(或视觉特征)。 | ||
| 56 | -- **同名陷阱**:如果解说提到“又是**凯恩**进球了”,不能只看凯恩,必须看**怎么进的**(头球还是点球)。 | ||
| 57 | -- **无匹配处理**:如果所有候选片段在视觉动作(如射门方式、进球位置)或关键事件逻辑上与回放明显不符,必须判定为无匹配,将 `video_id` 设为 `null`。 | 100 | +# Output Format (输出要求) |
| 58 | 101 | ||
| 59 | -### 输出要求 | 102 | +请**仅**输出一个标准的 JSON 对象,**不要**包含任何推理过程(reasoning)、Markdown 标记(如 ```json)或额外的解释文本。 |
| 60 | 103 | ||
| 61 | -请仅输出一个JSON格式的结果,不要输出任何分析过程。不要包含 markdown 标记(如 ```json ... ```),不要包含任何解释或额外文本。 | ||
| 62 | -格式如下: | 104 | +JSON 格式如下: |
| 63 | { | 105 | { |
| 64 | "replay_summary": { | 106 | "replay_summary": { |
| 65 | - "audio_cues": "解说提到的关键信息(如:'第15分钟', '远射', '德布劳内')", | ||
| 66 | - "visual_cues": "视觉关键特征(如:'禁区外右脚', '球挂死角', '门将飞身扑救')" | 107 | + "visual_cues": "提取回放画面的核心特征(如:'蓝队6号禁区外远射', '门将扑救不及', '比分变为1-1')" |
| 67 | }, | 108 | }, |
| 68 | - "reasoning": "综合分析:回放解说提到是'上半场的远射',视觉显示'17号球员禁区外起脚'。Candidate_1 视觉是'近距离推射',排除。Candidate_2 视觉是'禁区外远射',且门将动作一致,虽然直播时间显示是下半场(可能是集锦回顾),但解说也提到了'回顾上半场',确认为同一事件。", | ||
| 69 | - "video_id": "Candidate_2" | ||
| 70 | -}""" | ||
| 71 | - | 109 | + "video_id": "匹配到的直播片段ID,如果没有匹配则填 null" |
| 110 | +} | ||
| 111 | +""" | ||
| 72 | 112 | ||
| 73 | class FootballReplayMatchLive: | 113 | class FootballReplayMatchLive: |
| 74 | def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key', cache_dir:str=None, save_frames_enable:bool=False): | 114 | def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key', cache_dir:str=None, save_frames_enable:bool=False): |
| @@ -81,7 +121,12 @@ class FootballReplayMatchLive: | @@ -81,7 +121,12 @@ class FootballReplayMatchLive: | ||
| 81 | # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | 121 | # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, |
| 82 | # api_key='no_key') | 122 | # api_key='no_key') |
| 83 | self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, | 123 | self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, |
| 84 | - extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | 124 | + |
| 125 | + model_kwargs={ | ||
| 126 | + "extra_body": {"enable_thinking": False} | ||
| 127 | + } | ||
| 128 | + ) | ||
| 129 | + #extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | ||
| 85 | self.cache_dir = cache_dir | 130 | self.cache_dir = cache_dir |
| 86 | self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) | 131 | self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) |
| 87 | 132 | ||
| @@ -91,12 +136,12 @@ class FootballReplayMatchLive: | @@ -91,12 +136,12 @@ class FootballReplayMatchLive: | ||
| 91 | return None | 136 | return None |
| 92 | elif len(live_videos) == 1: | 137 | elif len(live_videos) == 1: |
| 93 | live_video_path = live_videos[0].get("url", None) | 138 | live_video_path = live_videos[0].get("url", None) |
| 94 | - live_video_id = live_videos[0].get("video_id", os.path.basename(live_video_path)) | ||
| 95 | - asr_text = live_videos[0].get("asr_text", '') | 139 | + live_video_id = live_videos[0].get("id", os.path.basename(live_video_path)) |
| 140 | + # asr_text = live_videos[0].get("asr_text", '') | ||
| 96 | live = { | 141 | live = { |
| 97 | "video_id": live_video_id, | 142 | "video_id": live_video_id, |
| 98 | - "video_path": live_video_path, | ||
| 99 | - "asr_text": asr_text, | 143 | + "url": live_video_path, |
| 144 | + # "asr_text": asr_text, | ||
| 100 | } | 145 | } |
| 101 | if record is not None: | 146 | if record is not None: |
| 102 | record.append({"live": live, "llm_result": None, 'live_list': [live]}) | 147 | record.append({"live": live, "llm_result": None, 'live_list': [live]}) |
| @@ -107,9 +152,9 @@ class FootballReplayMatchLive: | @@ -107,9 +152,9 @@ class FootballReplayMatchLive: | ||
| 107 | live_records = {} | 152 | live_records = {} |
| 108 | for live_video in live_videos: | 153 | for live_video in live_videos: |
| 109 | live_video_path = live_video.get("url", None) | 154 | live_video_path = live_video.get("url", None) |
| 110 | - live_video_id = live_video.get("video_id", os.path.basename(live_video_path)) | 155 | + live_video_id = live_video.get("id", os.path.basename(live_video_path)) |
| 111 | event_utc = live_video.get("event_utc", None) | 156 | event_utc = live_video.get("event_utc", None) |
| 112 | - asr_text = live_video.get("asr_text", '') | 157 | + # asr_text = live_video.get("asr_text", '') |
| 113 | live_video_contents = live_video.get("llm_contents", None) | 158 | live_video_contents = live_video.get("llm_contents", None) |
| 114 | if live_video_contents is None: | 159 | if live_video_contents is None: |
| 115 | # event_start = event_utc - timedelta(seconds=10) if event_utc is not None else None | 160 | # event_start = event_utc - timedelta(seconds=10) if event_utc is not None else None |
| @@ -124,38 +169,67 @@ class FootballReplayMatchLive: | @@ -124,38 +169,67 @@ class FootballReplayMatchLive: | ||
| 124 | roi=None, | 169 | roi=None, |
| 125 | max_px_area=400_000, | 170 | max_px_area=400_000, |
| 126 | prompt_start=f"### 候选片段 video_id: {live_video_id} ###", | 171 | prompt_start=f"### 候选片段 video_id: {live_video_id} ###", |
| 127 | - prompt_end=f"\n该片段解说内容: {asr_text}\n" | 172 | + prompt_end=f"\n该片段解说内容: 无\n" |
| 128 | ) | 173 | ) |
| 129 | live_map[live_video_id] = { | 174 | live_map[live_video_id] = { |
| 130 | "video_id": live_video_id, | 175 | "video_id": live_video_id, |
| 131 | - "video_path": live_video_path, | 176 | + "url": live_video_path, |
| 132 | "event_utc": event_utc, | 177 | "event_utc": event_utc, |
| 133 | - "asr_text": asr_text, | 178 | + # "asr_text": asr_text, |
| 134 | "contents": live_video_contents, | 179 | "contents": live_video_contents, |
| 135 | 180 | ||
| 136 | } | 181 | } |
| 137 | live_records[live_video_id] = { | 182 | live_records[live_video_id] = { |
| 138 | "video_id": live_video_id, | 183 | "video_id": live_video_id, |
| 139 | - "video_path": live_video_path, | 184 | + "url": live_video_path, |
| 140 | "event_utc": event_utc, | 185 | "event_utc": event_utc, |
| 141 | - "asr_text": asr_text, | 186 | + # "asr_text": asr_text, |
| 142 | } | 187 | } |
| 143 | live_videos_contents.extend(live_video_contents) | 188 | live_videos_contents.extend(live_video_contents) |
| 144 | 189 | ||
| 145 | user_contents.extend(replay_video_contents) | 190 | user_contents.extend(replay_video_contents) |
| 146 | user_contents.append({'type': "text", "text": "\n【候选直播片段列表】\n"}) | 191 | user_contents.append({'type': "text", "text": "\n【候选直播片段列表】\n"}) |
| 147 | user_contents.extend(live_videos_contents) | 192 | user_contents.extend(live_videos_contents) |
| 148 | - user_contents.append({'type': "text", "text": "\n请根据上述片段进行匹配并按要求输出结果\n"}) | 193 | + user_contents.append({'type': "text", "text": "\n请根据上述片段进行匹配并按要求输出结果/no_think\n"}) |
| 149 | system_message = SystemMessage(content=req_prompt) | 194 | system_message = SystemMessage(content=req_prompt) |
| 150 | user_message = HumanMessage(content=user_contents) | 195 | user_message = HumanMessage(content=user_contents) |
| 151 | result = self.model.invoke([system_message, user_message]).content | 196 | result = self.model.invoke([system_message, user_message]).content |
| 197 | + | ||
| 198 | + | ||
| 199 | + print(100*"3") | ||
| 200 | + print(result) | ||
| 201 | + print(100*"4") | ||
| 202 | + # try: | ||
| 203 | + # result_json = json.loads(result) | ||
| 204 | + # except json.JSONDecodeError: | ||
| 205 | + # try: | ||
| 206 | + # result_json = json.loads(result.replace("```json", "").replace("```", "")) | ||
| 207 | + # except Exception as e: | ||
| 208 | + # print("JSON解析失败:", result) | ||
| 209 | + # raise e | ||
| 210 | + | ||
| 152 | try: | 211 | try: |
| 153 | - result_json = json.loads(result) | ||
| 154 | - except json.JSONDecodeError: | ||
| 155 | - try: | ||
| 156 | - result_json = json.loads(result.replace("```json", "").replace("```", "")) | 212 | + # 1. 深度清洗:去掉 Markdown 标记 |
| 213 | + cleaned_result = result.replace("```json", "").replace("```", "") | ||
| 214 | + | ||
| 215 | + # 2. 核心修复:使用 re.DOTALL 确保正则能跨行匹配,精准提取最外层完整的 JSON 对象 | ||
| 216 | + # 这样可以防止因为模型输出换行导致提取不完整 | ||
| 217 | + json_match = re.search(r'(\{.*\})', cleaned_result, re.DOTALL) | ||
| 218 | + | ||
| 219 | + if json_match: | ||
| 220 | + json_str = json_match.group(1) | ||
| 221 | + # 截断第一个 } 之后的所有内容,彻底干掉 <time_location> 等尾巴 | ||
| 222 | + json_str = json_str[:json_str.rfind('}') + 1] | ||
| 223 | + else: | ||
| 224 | + # 如果连花括号都找不到,直接抛出原始内容方便排查 | ||
| 225 | + print("无法提取JSON,原始内容:", result) | ||
| 226 | + raise ValueError("响应中未找到有效的 JSON 对象") | ||
| 227 | + | ||
| 228 | + # 3. 使用 strict=False 容忍字符串中的非法控制字符(如直接换行) | ||
| 229 | + result_json = json.loads(json_str, strict=False) | ||
| 230 | + | ||
| 157 | except Exception as e: | 231 | except Exception as e: |
| 158 | - print("JSON解析失败:", result) | 232 | + print("JSON解析依然失败,清洗后的内容:", json_str if 'json_str' in locals() else result) |
| 159 | raise e | 233 | raise e |
| 160 | 234 | ||
| 161 | video_id = result_json.get("video_id", None) | 235 | video_id = result_json.get("video_id", None) |
| @@ -233,9 +307,9 @@ class FootballReplayMatchLive: | @@ -233,9 +307,9 @@ class FootballReplayMatchLive: | ||
| 233 | if result is not None: | 307 | if result is not None: |
| 234 | result_no_content = { | 308 | result_no_content = { |
| 235 | "video_id": result.get("video_id", None), | 309 | "video_id": result.get("video_id", None), |
| 236 | - "video_path": result.get("video_path", None), | 310 | + "url": result.get("url", None), |
| 237 | "event_utc": result.get("event_utc", None), | 311 | "event_utc": result.get("event_utc", None), |
| 238 | - "asr_text": result.get("asr_text", None), | 312 | + # "asr_text": result.get("asr_text", None), |
| 239 | } | 313 | } |
| 240 | else: | 314 | else: |
| 241 | result_no_content = None | 315 | result_no_content = None |
| @@ -76,15 +76,20 @@ class FootballReplayVideoEvent: | @@ -76,15 +76,20 @@ class FootballReplayVideoEvent: | ||
| 76 | # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, | 76 | # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7, |
| 77 | # api_key='no_key') | 77 | # api_key='no_key') |
| 78 | self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, | 78 | self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, |
| 79 | - extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | 79 | + model_kwargs={ |
| 80 | + "extra_body": {"enable_thinking": False} | ||
| 81 | + } | ||
| 82 | + ) | ||
| 83 | + # extra_body={"enable_thinking": False}) | ||
| 84 | + # extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | ||
| 80 | 85 | ||
| 81 | 86 | ||
| 82 | self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) | 87 | self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) |
| 83 | 88 | ||
| 84 | def video_event(self, replay_pack: dict, asr_text: str = '无', cache_dir=None, task_log_dir=None): | 89 | def video_event(self, replay_pack: dict, asr_text: str = '无', cache_dir=None, task_log_dir=None): |
| 85 | replay_video_path = replay_pack.get("url", None) | 90 | replay_video_path = replay_pack.get("url", None) |
| 86 | - | ||
| 87 | - cache_path = os.path.join(task_log_dir, 'video_event.json') | 91 | + video_name = os.path.basename(replay_video_path) |
| 92 | + cache_path = os.path.join(task_log_dir, f'{video_name}_replay.json') | ||
| 88 | if cache_path is not None and os.path.exists(cache_path): | 93 | if cache_path is not None and os.path.exists(cache_path): |
| 89 | with open(cache_path, 'r', encoding='utf-8') as f: | 94 | with open(cache_path, 'r', encoding='utf-8') as f: |
| 90 | return json.loads(f.read()) | 95 | return json.loads(f.read()) |
| @@ -104,8 +109,8 @@ class FootballReplayVideoEvent: | @@ -104,8 +109,8 @@ class FootballReplayVideoEvent: | ||
| 104 | 109 | ||
| 105 | system_message = SystemMessage(content=req_prompt) | 110 | system_message = SystemMessage(content=req_prompt) |
| 106 | video_message = HumanMessage(content=contents) | 111 | video_message = HumanMessage(content=contents) |
| 107 | - asr_message = HumanMessage(content=f"解说内容:{asr_text}") | ||
| 108 | - result = self.model.invoke([system_message, video_message, asr_message]).content | 112 | + # asr_message = HumanMessage(content=f"解说内容:{asr_text}") |
| 113 | + result = self.model.invoke([system_message, video_message]).content | ||
| 109 | try: | 114 | try: |
| 110 | result_json = json.loads(result) | 115 | result_json = json.loads(result) |
| 111 | except json.JSONDecodeError: | 116 | except json.JSONDecodeError: |
-
Please register or login to post a comment