lizhengwei

jira:NYJ-1540 desc: adjust

@@ -3,12 +3,23 @@ from aabd.base.enhance_dict import value_or_default @@ -3,12 +3,23 @@ from aabd.base.enhance_dict import value_or_default
3 3
4 logger = logging.getLogger(__name__) 4 logger = logging.getLogger(__name__)
5 5
  6 +from utils.football_replay_video_event_by_llm import FootballReplayVideoEvent
  7 +from utils.football_replay_match_live import FootballReplayMatchLive
6 8
7 class FootballReplayMatch: 9 class FootballReplayMatch:
8 def __init__(self, settings): 10 def __init__(self, settings):
9 self.settings = settings 11 self.settings = settings
10 self.match_by_time_threshold = value_or_default(settings.match_by_time_threshold, 30) * 1000 12 self.match_by_time_threshold = value_or_default(settings.match_by_time_threshold, 30) * 1000
11 13
  14 + llm_base_url = value_or_default(settings.llm.base_url, None)
  15 + model_name = value_or_default(settings.llm.model_name, None)
  16 + temperature = value_or_default(settings.llm.temperature, 0.7)
  17 + self.cache_dir = value_or_default(settings.common.cache_dir, None)
  18 + save_frames_enable = value_or_default(settings.save_frames_enable, False)
  19 +
  20 + self.videoEventRecognition = FootballReplayVideoEvent(llm_base_url, model_name, temperature, 'no_key', self.cache_dir, save_frames_enable)
  21 + self.videoMatchLive = FootballReplayMatchLive(llm_base_url, model_name, temperature, 'no_key', self.cache_dir)
  22 +
12 def match_by_time(self, replay, events): 23 def match_by_time(self, replay, events):
13 start_utc = replay.get('start_utc') 24 start_utc = replay.get('start_utc')
14 25
@@ -21,10 +32,11 @@ class FootballReplayMatch: @@ -21,10 +32,11 @@ class FootballReplayMatch:
21 return None 32 return None
22 33
23 def det_goal_replay(self, replay): 34 def det_goal_replay(self, replay):
24 - pass 35 + return self.videoEventRecognition.video_event(replay, cache_path=self.cache_dir)
  36 +
25 37
26 def match_by_llm(self, replay, events): 38 def match_by_llm(self, replay, events):
27 - pass 39 + return self.videoMatchLive.match_batch(replay, events, max_parallel=2, cache_path=self.cache_dir)
28 40
29 def replay_match_event(self, data): 41 def replay_match_event(self, data):
30 """ 42 """
@@ -48,6 +60,8 @@ class FootballReplayMatch: @@ -48,6 +60,8 @@ class FootballReplayMatch:
48 } 60 }
49 :return: 61 :return:
50 """ 62 """
  63 + task_id = data.get("id")
  64 + match_id = data.get("match_id")
51 65
52 # 该时间段附近是否有进球事件 66 # 该时间段附近是否有进球事件
53 replay_info = data['replay'] 67 replay_info = data['replay']
@@ -62,143 +76,25 @@ class FootballReplayMatch: @@ -62,143 +76,25 @@ class FootballReplayMatch:
62 if replay_event_name == '进球': 76 if replay_event_name == '进球':
63 matched_event = self.match_by_llm(replay_info, goal_live_events) 77 matched_event = self.match_by_llm(replay_info, goal_live_events)
64 if matched_event is None: 78 if matched_event is None:
65 - logger.info(f'LLM认为是进球但是未能找到匹配的事件')  
66 - else:  
67 - logger.info('LLM找到进球的事件') 79 + logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 是进球但是未能找到匹配的事件")
68 else: 80 else:
69 - logger.info(f'LLM判断不是进球,无需匹配')  
70 - else:  
71 - logger.info(f'通过时间找到匹配的事件')  
72 -  
73 -  
74 -  
75 -import json  
76 -import os  
77 -from pathlib import Path  
78 -  
79 -  
80 -from .llm_image import Video2Frame  
81 -from ..util.football_replay_match_live import FootballReplayMatchLive  
82 -from ..config import settings  
83 -  
84 - 81 + replay_id = replay_info.get('id')
  82 + matched_event_id = matched_event.get('video_id')
85 83
86 -# 从配置中读取参数(配置项需在 config.yaml 或对应环境配置中定义)  
87 -_cache_dir = settings.common.get('cache_dir', None)  
88 -_llm_base_url = settings.llm.get('base_url', None)  
89 -_llm_model = settings.llm.get('model_name', None)  
90 -_llm_temperature = settings.llm.get('temperature', None) 84 + logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}")
91 85
92 -# 初始化视频处理与匹配器  
93 -v2f = Video2Frame(_cache_dir)  
94 -matcher = FootballReplayMatchLive(  
95 - base_url=_llm_base_url,  
96 - model=_llm_model,  
97 - temperature=_llm_temperature,  
98 -)  
99 -  
100 -  
101 -def replay_match_event(data: dict) -> dict:  
102 - """  
103 - 足球回看与直播事件匹配接口。  
104 -  
105 - 请求参数:  
106 - id : str 任务唯一键  
107 - match_id : str 比赛id  
108 - replay : dict 回看信息  
109 - |- id : str 回看id  
110 - |- url : str 回看url(m3u8)  
111 - |- start_utc : int 回看开始utc时间  
112 - |- end_utc : int 回看结束utc时间  
113 - events : list 赛事事件片段(直播候选)  
114 - |- id : str 事件id  
115 - |- type : str 事件类型(1:进球)  
116 - |- url : str 完整视频url(m3u8)  
117 - |- event_utc : int 事件发生的utc时间点  
118 -  
119 - 响应参数:  
120 - id : str 任务唯一键  
121 - match_id : str 比赛id  
122 - replay_id : str 回看id  
123 - event_id : str|null 匹配到的比赛片段id,null表示无匹配  
124 - """  
125 - task_id = data.get("id")  
126 - match_id = data.get("match_id")  
127 - replay = data.get("replay", {})  
128 - events = data.get("events", [])  
129 -  
130 - replay_id = replay.get("id")  
131 - replay_url = replay.get("url")  
132 - replay_start = replay.get("start_utc")  
133 - replay_end = replay.get("end_utc")  
134 -  
135 - if not replay_url or replay_start is None or replay_end is None:  
136 - raise ValueError("replay 参数不完整,需要 url、start_utc、end_utc")  
137 -  
138 - # 1. 为回看视频提取帧并构建 LLM content  
139 - try:  
140 - replay_frames = v2f.to_frames(  
141 - url=replay_url,  
142 - start_utc=replay_start,  
143 - end_utc=replay_end,  
144 - fps=2,  
145 - )  
146 - replay_contents = frames2content(replay_frames)  
147 - # 保持与 football_replay_match_live.py 中一致的提示结构  
148 - replay_contents.insert(0, {"type": "text", "text": "\n【回放片段信息】\n"})  
149 - replay_contents.append({"type": "text", "text": "\n回放解说内容:无\n"})  
150 - except Exception as e:  
151 - raise RuntimeError(f"回看视频处理失败: {e}")  
152 -  
153 - # 2. 为每个 event(直播候选)提取帧并构建 LLM content  
154 - # event 的 url 为完整视频,通过 event_utc 向前延长10秒、向后延长5秒截取片段  
155 - live_videos = []  
156 - for event in events:  
157 - event_id = event.get("id")  
158 - event_url = event.get("url")  
159 - event_utc = event.get("event_utc")  
160 -  
161 - if not event_url or event_utc is None:  
162 - continue  
163 -  
164 - try:  
165 - event_start = event_utc - 10  
166 - event_end = event_utc + 5  
167 - event_frames = v2f.to_frames(  
168 - url=event_url,  
169 - start_utc=event_start,  
170 - end_utc=event_end,  
171 - fps=2,  
172 - )  
173 - event_contents = frames2content(event_frames)  
174 - event_contents.insert(  
175 - 0, {"type": "text", "text": f"### 候选片段 video_id: {event_id} ###"}  
176 - )  
177 - event_contents.append({"type": "text", "text": "\n该片段解说内容: 无\n"})  
178 - except Exception:  
179 - # 单个 event 处理失败不影响整体流程  
180 - continue  
181 -  
182 - live_videos.append({  
183 - "video_id": event_id,  
184 - "video_path": "", # 已提供 llm_contents,无需本地路径  
185 - "asr_text": "",  
186 - "llm_contents": event_contents,  
187 - })  
188 -  
189 - # 3. 执行匹配  
190 - if len(live_videos) == 0:  
191 - matched_event_id = None  
192 - elif len(live_videos) == 1:  
193 - # 只有一个候选片段,直接视为匹配结果  
194 - matched_event_id = live_videos[0]["video_id"] 86 + return {
  87 + "id": task_id,
  88 + "match_id": match_id,
  89 + "replay_id": replay_id,
  90 + "event_id": matched_event_id,
  91 + }
195 else: 92 else:
196 - try:  
197 - result = matcher._match_batch(replay_contents, live_videos, max_parallel=3)  
198 - matched_event_id = result.get("video_id") if result else None  
199 - except Exception:  
200 - matched_event_id = None  
201 - 93 + logger.info(f"{task_id}_LLM判断{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 不是进球,无需匹配")
  94 + else:
  95 + logger.info(f'通过时间找到匹配的事件')
  96 + replay_id = replay_info.get('id')
  97 + matched_event_id = matched_event.get('id')
202 return { 98 return {
203 "id": task_id, 99 "id": task_id,
204 "match_id": match_id, 100 "match_id": match_id,
1 -import base64  
2 -import hashlib  
3 -import subprocess  
4 -from pathlib import Path  
5 -  
6 -import cv2  
7 -  
8 -try:  
9 - from .m3u8_to_mp4_sei import (  
10 - download_m3u8_to_mp4,  
11 - extract_h264_es_from_mp4,  
12 - parse_h264_sei,  
13 - extract_utc_from_sei,  
14 - )  
15 -except ImportError:  
16 - from m3u8_to_mp4_sei import (  
17 - download_m3u8_to_mp4,  
18 - extract_h264_es_from_mp4,  
19 - parse_h264_sei,  
20 - extract_utc_from_sei,  
21 - )  
22 -  
23 -# 用于区分传入的 start_utc/end_utc 是绝对 UTC 时间戳还是秒偏移  
24 -_IS_ABSOLUTE_UTC_THRESHOLD = 1_000_000_000  
25 -  
26 -  
27 -class Video2Frame:  
28 - def __init__(self, cache_dir):  
29 - self.cache_dir = Path(cache_dir)  
30 - self.video_dir = self.cache_dir / "videos"  
31 - self.frames_dir = self.cache_dir / "frames"  
32 - self.video_dir.mkdir(parents=True, exist_ok=True)  
33 - self.frames_dir.mkdir(parents=True, exist_ok=True)  
34 -  
35 - def to_frames(self, url, start_utc, end_utc, fps, roi=None, max_px_area=None) -> list:  
36 - """  
37 - 下载 m3u8 视频流的指定片段并提取帧,保存到 cache_dir 下。  
38 -  
39 - - video_dir 中保存的是 start_utc ~ end_utc 截取后的视频片段(而非完整视频)。  
40 - - 如果视频中包含 SEI UTC 信息且传入的 start_utc/end_utc 为绝对时间戳,  
41 - 则会基于 SEI UTC 进行帧级定位;否则将 start_utc/end_utc 视为秒偏移量。  
42 -  
43 - Args:  
44 - url: m3u8 视频流地址。  
45 - start_utc: 截取开始时间(秒偏移或绝对 UTC 时间戳)。  
46 - end_utc: 截取结束时间(秒偏移或绝对 UTC 时间戳)。  
47 - fps: 目标抽帧帧率。  
48 - roi: 感兴趣区域 (x, y, w, h),可选。  
49 - max_px_area: 最大像素面积,超过则等比例缩小,可选。  
50 -  
51 - Returns:  
52 - 提取的帧图片路径列表。  
53 - """  
54 - if end_utc <= start_utc:  
55 - raise ValueError("end_utc 必须大于 start_utc")  
56 -  
57 - unique_str = f"{url}_{start_utc}_{end_utc}"  
58 - unique_id = hashlib.md5(unique_str.encode("utf-8")).hexdigest()  
59 -  
60 - clip_path = self.video_dir / f"{unique_id}.mp4"  
61 - frame_output_dir = self.frames_dir / unique_id  
62 - frame_output_dir.mkdir(parents=True, exist_ok=True)  
63 -  
64 - # 判断是否为绝对 UTC 时间戳  
65 - is_absolute_utc = (  
66 - start_utc > _IS_ABSOLUTE_UTC_THRESHOLD  
67 - and end_utc > _IS_ABSOLUTE_UTC_THRESHOLD  
68 - )  
69 -  
70 - # 若片段未缓存,先截取目标片段再保存  
71 - if not clip_path.exists():  
72 - if is_absolute_utc:  
73 - # 绝对 UTC 模式:先下载完整视频到临时文件,解析 SEI 后截取片段  
74 - temp_path = self.video_dir / f"{unique_id}_full.mp4"  
75 - download_m3u8_to_mp4(url, str(temp_path))  
76 -  
77 - # 提取 SEI UTC 信息  
78 - utc_records = []  
79 - try:  
80 - es_data = extract_h264_es_from_mp4(str(temp_path))  
81 - sei_list = parse_h264_sei(es_data)  
82 - utc_records = extract_utc_from_sei(sei_list)  
83 - except Exception:  
84 - pass  
85 -  
86 - if utc_records:  
87 - utc_list = [r["utc"] for r in utc_records]  
88 - start_frame_idx = self._find_frame_idx(  
89 - utc_list, start_utc, find_last_not_greater=False  
90 - )  
91 - end_frame_idx = self._find_frame_idx(  
92 - utc_list, end_utc, find_last_not_greater=True  
93 - )  
94 -  
95 - cap_temp = cv2.VideoCapture(str(temp_path))  
96 - video_fps = cap_temp.get(cv2.CAP_PROP_FPS)  
97 - cap_temp.release()  
98 - if video_fps <= 0:  
99 - video_fps = 25.0  
100 -  
101 - start_sec = start_frame_idx / video_fps  
102 - duration = (end_frame_idx - start_frame_idx) / video_fps  
103 - self._ffmpeg_extract_clip(  
104 - str(temp_path), str(clip_path), start_sec, duration  
105 - )  
106 - else:  
107 - # 无 SEI 无法定位,直接将完整视频重命名为片段  
108 - temp_path.rename(clip_path)  
109 -  
110 - # 清理临时完整视频  
111 - if temp_path.exists():  
112 - temp_path.unlink(missing_ok=True)  
113 - else:  
114 - # 秒偏移模式:直接用 ffmpeg 从 URL 截取片段  
115 - duration = end_utc - start_utc  
116 - self._ffmpeg_download_clip(  
117 - url, str(clip_path), start_utc, duration  
118 - )  
119 -  
120 - # 从截取后的片段中提取帧  
121 - cap = cv2.VideoCapture(str(clip_path))  
122 - if not cap.isOpened():  
123 - raise ValueError(f"无法打开视频片段: {clip_path}")  
124 -  
125 - # total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))  
126 - video_fps = cap.get(cv2.CAP_PROP_FPS)  
127 - if video_fps <= 0:  
128 - video_fps = 25.0  
129 -  
130 - # 计算抽帧间隔  
131 - frame_interval = int(video_fps / fps) if fps > 0 else int(video_fps)  
132 - if frame_interval < 1:  
133 - frame_interval = 1  
134 -  
135 - frames = []  
136 - saved_count = 0  
137 - frame_count = 0  
138 - while True:  
139 - ret, frame = cap.read()  
140 - if not ret:  
141 - break  
142 -  
143 - if frame_count % frame_interval == 0:  
144 - if roi is not None:  
145 - x, y, w, h = roi  
146 - frame = frame[y:y + h, x:x + w]  
147 - frame = self.reisize_frame(frame, max_px_area)  
148 - frames.append(frame)  
149 - frame_path = frame_output_dir / f"frame_{saved_count:06d}.jpg"  
150 - cv2.imwrite(str(frame_path), frame)  
151 - saved_count += 1  
152 - frame_count += 1  
153 - cap.release()  
154 - return frames  
155 -  
156 - def reisize_frame(self, frame, max_px_area):  
157 - if max_px_area is None:  
158 - return frame  
159 - h, w = frame.shape[:2]  
160 - area = h * w  
161 - if area > max_px_area:  
162 - scale = (max_px_area / area) ** 0.5  
163 - new_w = int(w * scale)  
164 - new_h = int(h * scale)  
165 - frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA)  
166 - return frame  
167 -  
168 - @staticmethod  
169 - def _find_frame_idx(utc_list, target_utc, find_last_not_greater=True):  
170 - """  
171 - 在 utc_list 中查找最接近 target_utc 的帧索引。  
172 -  
173 - Args:  
174 - utc_list: 按帧顺序排列的 UTC 列表。  
175 - target_utc: 目标 UTC。  
176 - find_last_not_greater: True 返回最后一个 <= target_utc 的索引;  
177 - False 返回第一个 >= target_utc 的索引。  
178 - """  
179 - if not utc_list:  
180 - return 0  
181 -  
182 - if find_last_not_greater:  
183 - idx = 0  
184 - for i, utc in enumerate(utc_list):  
185 - if utc > target_utc:  
186 - break  
187 - idx = i  
188 - return idx  
189 - else:  
190 - for i, utc in enumerate(utc_list):  
191 - if utc >= target_utc:  
192 - return i  
193 - return len(utc_list) - 1  
194 -  
195 - @staticmethod  
196 - def _ffmpeg_extract_clip(input_path, output_path, start_sec, duration):  
197 - """使用 ffmpeg 从本地视频截取指定片段。"""  
198 - cmd = [  
199 - "ffmpeg", "-y",  
200 - "-i", input_path,  
201 - "-ss", str(start_sec),  
202 - "-t", str(duration),  
203 - "-c", "copy",  
204 - "-bsf:a", "aac_adtstoasc",  
205 - "-movflags", "+faststart",  
206 - output_path,  
207 - ]  
208 - try:  
209 - subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)  
210 - except subprocess.CalledProcessError as e:  
211 - raise RuntimeError(  
212 - f"ffmpeg 截取片段失败: {e.stderr.decode('utf-8', errors='ignore')}"  
213 - )  
214 -  
215 - @staticmethod  
216 - def _ffmpeg_download_clip(url, output_path, start_sec, duration):  
217 - """使用 ffmpeg 从 URL 下载并截取指定片段。"""  
218 - cmd = [  
219 - "ffmpeg", "-y",  
220 - "-ss", str(start_sec),  
221 - "-fflags", "+discardcorrupt",  
222 - "-i", url,  
223 - "-t", str(duration),  
224 - "-c", "copy",  
225 - "-bsf:a", "aac_adtstoasc",  
226 - "-movflags", "+faststart",  
227 - output_path,  
228 - ]  
229 - try:  
230 - subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE)  
231 - except subprocess.CalledProcessError as e:  
232 - raise RuntimeError(  
233 - f"ffmpeg 下载片段失败: {e.stderr.decode('utf-8', errors='ignore')}"  
234 - )  
235 -  
236 -  
237 -def frames2content(frames):  
238 - """  
239 - 将帧列表(图片路径列表)转为 LLM content 列表。  
240 - 格式与 llm_video_content.py 中的 contents 返回的 content 一致。  
241 - """  
242 - contents = []  
243 - video_prompt = (  
244 - f"以下是从视频中按时间顺序提取的 {len(frames)} 帧画面,"  
245 - f"请将它们视为一个连续的视频进行分析。"  
246 - )  
247 - contents.append({"type": "text", "text": video_prompt})  
248 -  
249 - for frame in frames:  
250 - _, buffer = cv2.imencode('.jpg', frame)  
251 - b64_str = base64.b64encode(buffer).decode('utf-8')  
252 -  
253 - # with open(frame_path, "rb") as f:  
254 - # img_bytes = f.read()  
255 - # b64_str = base64.b64encode(img_bytes).decode("utf-8")  
256 -  
257 - contents.append({  
258 - "type": "image_url",  
259 - "image_url": {  
260 - "url": f"data:image/jpeg;base64,{b64_str}"  
261 - }  
262 - })  
263 -  
264 - return contents  
265 -  
266 -  
267 -if __name__ == "__main__":  
268 - # pass  
269 -  
270 - import argparse  
271 - import sys  
272 - import tempfile  
273 -  
274 - parser = argparse.ArgumentParser(description="Video2Frame 测试脚本")  
275 - parser.add_argument(  
276 - "--url",  
277 - default=r"D:\pythonProject\learn\3b3e99cf4ca84c3782503d8817242de2.mp4",  
278 - help="测试用 m3u8 地址(默认使用公开测试流)",  
279 - )  
280 - parser.add_argument(  
281 - "--cache-dir",  
282 - default=r"C:\Users\lzw\AppData\Local\Temp\video2frame_test_mn26tcy_my",  
283 - help="缓存目录(默认自动创建临时目录)",  
284 - )  
285 - parser.add_argument(  
286 - "--start",  
287 - type=float,  
288 - default=0,  
289 - help="截取开始时间,单位:秒(默认 0)",  
290 - )  
291 - parser.add_argument(  
292 - "--end",  
293 - type=float,  
294 - default=10,  
295 - help="截取结束时间,单位:秒(默认 10)",  
296 - )  
297 - parser.add_argument(  
298 - "--fps",  
299 - type=float,  
300 - default=2.0,  
301 - help="抽帧帧率(默认 1.0)",  
302 - )  
303 - args = parser.parse_args()  
304 -  
305 - cache_dir = args.cache_dir or tempfile.mkdtemp(prefix="video2frame_test_")  
306 - print(f"[INFO] 缓存目录: {cache_dir}")  
307 -  
308 - v2f = Video2Frame(cache_dir)  
309 -  
310 - # TEST 1: 基本抽帧  
311 - print(  
312 - f"\n[TEST 1] to_frames: url={args.url}, "  
313 - f"start={args.start}s, end={args.end}s, fps={args.fps}"  
314 - )  
315 - try:  
316 - frames = v2f.to_frames(  
317 - url=args.url,  
318 - start_utc=args.start,  
319 - end_utc=args.end,  
320 - fps=args.fps,  
321 - roi=None,  
322 - max_px_area=200_000,  
323 - )  
324 - print(f" 成功提取 {len(frames)} 帧")  
325 - if frames:  
326 - print(f" 首帧: {frames[0].shape}")  
327 - print(f" 末帧: {frames[-1].shape}")  
328 - except Exception as e:  
329 - print(f" 失败: {e}")  
330 - import traceback  
331 -  
332 - traceback.print_exc()  
333 - sys.exit(1)  
334 -  
335 - # TEST 2: frames2content  
336 - print("\n[TEST 2] frames2content")  
337 - content = frames2content(frames)  
338 - print(f" content 列表长度: {len(content)}")  
339 - for item in content[:3]:  
340 - preview = (  
341 - item["text"][:60] + "..."  
342 - if item["type"] == "text"  
343 - else item["image_url"]["url"][:60] + "..."  
344 - )  
345 - print(f" - type={item['type']}: {preview}")  
346 - if len(content) > 3:  
347 - print(f" ... 还有 {len(content) - 3} 个元素")  
348 -  
349 - print("\n[TEST 3] FootballReplayVideoEvent 进球识别(可选)")  
350 -  
351 - try:  
352 - from ..util.football_replay_video_event_by_llm import FootballReplayVideoEvent  
353 - except ImportError:  
354 - import sys  
355 - from pathlib import Path  
356 - sys.path.insert(0, str(Path(__file__).parent.parent))  
357 - from util.football_replay_video_event_by_llm import FootballReplayVideoEvent  
358 -  
359 - fbrv = FootballReplayVideoEvent(  
360 - base_url="http://192.168.1.59:11434",  
361 - model="qwen3.6:35b-a3b-q8_0",  
362 - temperature=0.7,  
363 - )  
364 - event_json = fbrv.video_event_for_contents(content, None)  
365 - print(f" 识别结果: {event_json}")  
366 -  
367 -  
368 - # # TEST 3: ROI + max_px_area  
369 - # print("\n[TEST 3] to_frames with roi + max_px_area")  
370 - # try:  
371 - # end_crop = min(args.start + 5, args.end)  
372 - # frames_cropped = v2f.to_frames(  
373 - # url=args.url,  
374 - # start_utc=args.start,  
375 - # end_utc=end_crop,  
376 - # fps=1.0,  
377 - # roi=None,  
378 - # max_px_area=200_000,  
379 - # )  
380 - # print(f" 成功提取 {len(frames_cropped)} 帧(带 ROI 裁剪和面积缩放)")  
381 - # except Exception as e:  
382 - # print(f" 跳过/失败: {e}")  
383 - #  
384 - # # TEST 4: SEI UTC 模式说明(仅演示,需真实含 SEI 的流才能运行)  
385 - # print("\n[TEST 4] SEI UTC 绝对时间戳模式(示例代码,需替换为含 SEI 的流)")  
386 - # print(  
387 - # " # 示例:假设视频第一帧 UTC 为 1715600000,提取 10 秒片段\n"  
388 - # " # frames = v2f.to_frames(url, start_utc=1715600000, end_utc=1715600010, fps=1)"  
389 - # )  
390 - #  
391 - # print("\n[INFO] 所有测试完成")  
1 -#!/usr/bin/env python3  
2 -"""  
3 -m3u8_to_mp4_sei.py  
4 -下载 m3u8 为 mp4,并提取视频中的 SEI UTC 信息。  
5 -  
6 -依赖:  
7 - ffmpeg (需要添加到系统 PATH)  
8 -  
9 -用法示例:  
10 - python m3u8_to_mp4_sei.py "http://example.com/playlist.m3u8"  
11 - python m3u8_to_mp4_sei.py "http://example.com/playlist.m3u8" -o myvideo.mp4 --json  
12 - python m3u8_to_mp4_sei.py "http://example.com/playlist.m3u8" -t 30 --json  
13 -"""  
14 -  
15 -import argparse  
16 -import json  
17 -import os  
18 -import struct  
19 -import subprocess  
20 -import sys  
21 -from pathlib import Path  
22 -from typing import Dict, List, Optional  
23 -  
24 -  
25 -def check_ffmpeg() -> bool:  
26 - """检查系统是否安装了 ffmpeg"""  
27 - try:  
28 - subprocess.run(["ffmpeg", "-version"], capture_output=True, check=True)  
29 - return True  
30 - except (FileNotFoundError, subprocess.CalledProcessError):  
31 - return False  
32 -  
33 -  
34 -def download_m3u8_to_mp4(url: str, output_path: str, duration: Optional[int] = None) -> None:  
35 - """  
36 - 使用 ffmpeg 将 m3u8 下载并封装为 mp4  
37 - :param duration: 若指定,则只下载前 N 秒  
38 - """  
39 - cmd = [  
40 - "ffmpeg",  
41 - "-y",  
42 - "-fflags", "+discardcorrupt", # 容错:丢弃损坏包  
43 - "-i", url,  
44 - ]  
45 - if duration is not None:  
46 - cmd.extend(["-t", str(duration)])  
47 - cmd.extend([  
48 - "-c", "copy",  
49 - "-bsf:a", "aac_adtstoasc",  
50 - "-movflags", "+faststart",  
51 - output_path,  
52 - ])  
53 - print(f"[1/3] 正在下载并封装 mp4: {output_path}")  
54 - result = subprocess.run(cmd, capture_output=True, text=True, encoding="utf-8", errors="replace")  
55 - if result.returncode != 0:  
56 - raise RuntimeError(f"ffmpeg 下载失败: {result.stderr}")  
57 -  
58 -  
59 -def extract_h264_es_from_mp4(mp4_path: str) -> bytes:  
60 - """  
61 - 使用 ffmpeg 从 mp4 中提取 H.264 Elementary Stream (AnnexB 格式)  
62 - 返回完整的 ES 字节流  
63 - """  
64 - cmd = [  
65 - "ffmpeg",  
66 - "-y",  
67 - "-i", mp4_path,  
68 - "-c:v", "copy",  
69 - "-an",  
70 - "-bsf:v", "h264_mp4toannexb",  
71 - "-f", "h264",  
72 - "pipe:1",  
73 - ]  
74 - print("[2/3] 正在从 mp4 中提取 H.264 视频流...")  
75 - proc = subprocess.run(cmd, capture_output=True)  
76 - if proc.returncode != 0:  
77 - stderr = proc.stderr.decode("utf-8", errors="replace") if proc.stderr else ""  
78 - raise RuntimeError(f"ffmpeg 提取视频流失败: {stderr}")  
79 - return proc.stdout  
80 -  
81 -  
82 -def _sei_type_name(payload_type: int) -> str:  
83 - names = {  
84 - 0: "buffering_period",  
85 - 1: "pic_timing",  
86 - 2: "pan_scan_rect",  
87 - 3: "filler_payload",  
88 - 4: "user_data_registered_itu_t_t35",  
89 - 5: "user_data_unregistered",  
90 - 6: "recovery_point",  
91 - 7: "dec_ref_pic_marking_repetition",  
92 - 8: "spare_pic",  
93 - 9: "scene_info",  
94 - 10: "sub_seq_info",  
95 - 11: "sub_seq_layer_characteristics",  
96 - 12: "sub_seq_characteristics",  
97 - 13: "full_frame_freeze",  
98 - 14: "full_frame_freeze_release",  
99 - 15: "full_frame_snapshot",  
100 - 16: "progressive_refinement_segment_start",  
101 - 17: "progressive_refinement_segment_end",  
102 - 18: "motion_constrained_slice_group_set",  
103 - 19: "film_grain_characteristics",  
104 - 20: "deblocking_filter_display_preference",  
105 - 21: "stereo_video_info",  
106 - 22: "post_filter_hint",  
107 - 23: "tone_mapping_info",  
108 - 24: "scalability_info",  
109 - 25: "sub_pic_scalable_layer",  
110 - 26: "non_required_layer_rep",  
111 - 27: "priority_layer_info",  
112 - 28: "layers_not_present",  
113 - 29: "layer_dependency_change",  
114 - 30: "scalable_nesting",  
115 - 31: "base_layer_temporal_hrd",  
116 - 32: "quality_layer_integrity_check",  
117 - 33: "redundant_pic_property",  
118 - 34: "tl0_dep_rep_index",  
119 - 35: "tl_switching_point",  
120 - 36: "parallel_decoding_info",  
121 - 37: "mvc_scalable_nesting",  
122 - 38: "view_scalability_info",  
123 - 39: "multiview_scene_info",  
124 - 40: "multiview_acquisition_info",  
125 - 41: "non_equivalent_view_dependency",  
126 - 42: "view_dependency_change",  
127 - 43: "operation_points_not_present",  
128 - 44: "base_view_temporal_hrd",  
129 - 45: "frame_packing_arrangement",  
130 - 46: "multiview_view_position",  
131 - 47: "display_orientation",  
132 - 48: "mvcd_scalable_nesting",  
133 - 49: "mvcd_view_scalability_info",  
134 - 50: "depth_representation_info",  
135 - 51: "three_dimensional_reference_displays_info",  
136 - 52: "depth_timing",  
137 - 53: "depth_sampling_info",  
138 - 54: "constrained_depth_parameter_set_identifier",  
139 - 55: "green_metadata",  
140 - 56: "mastering_display_colour_volume",  
141 - 57: "colour_remapping_info",  
142 - 58: "alternative_transfer_characteristics",  
143 - 59: "alternative_depth_info",  
144 - }  
145 - return names.get(payload_type, f"unknown({payload_type})")  
146 -  
147 -  
148 -def parse_h264_sei(es_data: bytes) -> List[Dict]:  
149 - """  
150 - 从 H.264 ES 数据中解析 SEI NAL 单元  
151 - 返回 SEI 列表,包含 payload_type, uuid, text/data  
152 - """  
153 - sei_list = []  
154 - i = 0  
155 -  
156 - def find_next_start(data: bytes, start: int) -> int:  
157 - j = start  
158 - while j + 3 < len(data):  
159 - if data[j:j + 4] == b"\x00\x00\x00\x01":  
160 - return j  
161 - if data[j:j + 3] == b"\x00\x00\x01":  
162 - return j  
163 - j += 1  
164 - return len(data)  
165 -  
166 - while i < len(es_data):  
167 - if es_data[i:i + 4] == b"\x00\x00\x00\x01":  
168 - start_len = 4  
169 - elif es_data[i:i + 3] == b"\x00\x00\x01":  
170 - start_len = 3  
171 - else:  
172 - i += 1  
173 - continue  
174 -  
175 - if i + start_len >= len(es_data):  
176 - break  
177 -  
178 - nal_unit_type = es_data[i + start_len] & 0x1F  
179 -  
180 - if nal_unit_type == 6: # SEI  
181 - payload_start = i + start_len + 1  
182 - next_start = find_next_start(es_data, payload_start)  
183 - sei_payload = es_data[payload_start:next_start]  
184 -  
185 - k = 0  
186 - payload_type = 0  
187 - while k < len(sei_payload) and sei_payload[k] == 0xFF:  
188 - payload_type += 255  
189 - k += 1  
190 - if k < len(sei_payload):  
191 - payload_type += sei_payload[k]  
192 - k += 1  
193 -  
194 - payload_size = 0  
195 - while k < len(sei_payload) and sei_payload[k] == 0xFF:  
196 - payload_size += 255  
197 - k += 1  
198 - if k < len(sei_payload):  
199 - payload_size += sei_payload[k]  
200 - k += 1  
201 -  
202 - if k + payload_size <= len(sei_payload):  
203 - payload_data = sei_payload[k:k + payload_size]  
204 -  
205 - sei_info = {  
206 - "payload_type": payload_type,  
207 - "payload_type_name": _sei_type_name(payload_type),  
208 - "payload_size": payload_size,  
209 - }  
210 -  
211 - if payload_type == 5: # user_data_unregistered  
212 - if len(payload_data) >= 16:  
213 - uuid_bytes = payload_data[:16]  
214 - user_data = payload_data[16:]  
215 - while user_data and user_data[-1] == 0x00:  
216 - user_data = user_data[:-1]  
217 - if user_data and user_data[-1] == 0x80:  
218 - user_data = user_data[:-1]  
219 -  
220 - sei_info["uuid_hex"] = uuid_bytes.hex()  
221 - sei_info["uuid_ascii"] = uuid_bytes.decode("ascii", errors="replace")  
222 - try:  
223 - text = user_data.decode("utf-8", errors="replace")  
224 - sei_info["text"] = text  
225 - try:  
226 - sei_info["json"] = json.loads(text)  
227 - except json.JSONDecodeError:  
228 - pass  
229 - except Exception:  
230 - sei_info["raw_hex"] = user_data.hex()  
231 -  
232 - sei_list.append(sei_info)  
233 -  
234 - i = next_start  
235 - else:  
236 - i += 1  
237 -  
238 - return sei_list  
239 -  
240 -  
241 -def extract_utc_from_sei(sei_list: List[Dict]) -> List[Dict]:  
242 - """从 SEI 列表中提取 UTC 信息"""  
243 - utc_records = []  
244 - seen = set()  
245 - for sei in sei_list:  
246 - json_data = sei.get("json")  
247 - if isinstance(json_data, dict) and "utc" in json_data:  
248 - # 去重:相同的 utc 只保留一次  
249 - key = json.dumps(json_data, sort_keys=True, ensure_ascii=False)  
250 - if key in seen:  
251 - continue  
252 - seen.add(key)  
253 - utc_records.append({  
254 - "utc": json_data["utc"],  
255 - "origin_utc": json_data.get("origin_utc"),  
256 - "origin_offset": json_data.get("origin_offset"),  
257 - "full_json": json_data,  
258 - })  
259 - return utc_records  
260 -  
261 -  
262 -def main():  
263 - parser = argparse.ArgumentParser(  
264 - description="将 m3u8 下载为 mp4 并提取 SEI UTC 信息",  
265 - formatter_class=argparse.RawDescriptionHelpFormatter,  
266 - epilog="""  
267 -示例:  
268 - %(prog)s "http://example.com/playlist.m3u8"  
269 - %(prog)s "http://example.com/playlist.m3u8" -o myvideo.mp4 --json  
270 - %(prog)s "http://example.com/playlist.m3u8" -t 30 --json -o result.json  
271 - """,  
272 - )  
273 - parser.add_argument("url", help="m3u8 播放列表 URL")  
274 - parser.add_argument("-o", "--output", default=None, help="mp4 输出路径(默认根据 URL 自动命名)")  
275 - parser.add_argument("-t", "--duration", type=int, default=None, help="只下载前 N 秒(默认下载全部)")  
276 - parser.add_argument("--json", action="store_true", help="以 JSON 格式输出结果")  
277 - parser.add_argument("--no-download", action="store_true", help="如果本地已有 mp4,跳过下载")  
278 - parser.add_argument("--save-es", metavar="FILE", help="保存提取的 H.264 ES 流到指定文件(调试用)")  
279 - args = parser.parse_args()  
280 -  
281 - # 强制 stdout UTF-8  
282 - import io  
283 - try:  
284 - if getattr(sys.stdout, "encoding", None) != "utf-8":  
285 - if hasattr(sys.stdout, "reconfigure"):  
286 - sys.stdout.reconfigure(encoding="utf-8")  
287 - elif hasattr(sys.stdout, "buffer"):  
288 - sys.stdout = io.TextIOWrapper(  
289 - sys.stdout.buffer, encoding="utf-8", line_buffering=True  
290 - )  
291 - except Exception:  
292 - pass  
293 -  
294 - if not check_ffmpeg():  
295 - print("错误: 未检测到 ffmpeg,请先安装并添加到 PATH。", file=sys.stderr)  
296 - print("下载地址: https://ffmpeg.org/download.html", file=sys.stderr)  
297 - sys.exit(1)  
298 -  
299 - # 确定 mp4 输出路径  
300 - if args.output:  
301 - mp4_path = Path(args.output)  
302 - else:  
303 - base = args.url.split("?")[0].rsplit("/", 1)[-1]  
304 - if not base or "." not in base:  
305 - base = "output.mp4"  
306 - else:  
307 - base = base.rsplit(".", 1)[0] + ".mp4"  
308 - mp4_path = Path(base)  
309 -  
310 - es_data = b""  
311 - try:  
312 - # 1. 下载/封装 mp4  
313 - if not args.no_download or not mp4_path.exists():  
314 - download_m3u8_to_mp4(args.url, str(mp4_path), duration=args.duration)  
315 - print(f" mp4 已保存: {mp4_path.absolute()}")  
316 - else:  
317 - print(f"[1/3] 使用本地 mp4: {mp4_path.absolute()}")  
318 -  
319 - # 2. 提取 H.264 ES  
320 - es_data = extract_h264_es_from_mp4(str(mp4_path))  
321 - print(f" 提取到 {len(es_data)} 字节的 H.264 ES 数据")  
322 -  
323 - if args.save_es:  
324 - es_path = Path(args.save_es)  
325 - es_path.write_bytes(es_data)  
326 - print(f" H.264 ES 已保存: {es_path.absolute()}")  
327 -  
328 - # 3. 解析 SEI  
329 - sei_list = parse_h264_sei(es_data)  
330 - print(f"[3/3] 解析完成,发现 {len(sei_list)} 条 SEI 消息")  
331 -  
332 - # 4. 提取 UTC  
333 - utc_records = extract_utc_from_sei(sei_list)  
334 -  
335 - result = {  
336 - "m3u8_url": args.url,  
337 - "mp4_path": str(mp4_path.absolute()),  
338 - "sei_count": len(sei_list),  
339 - "utc_records": utc_records,  
340 - "sei_items": sei_list[:20],  
341 - }  
342 -  
343 - # 输出  
344 - json_str = json.dumps(result, ensure_ascii=False, indent=2)  
345 - if args.json and args.output and args.output.endswith(".json"):  
346 - # 如果 --output 以 .json 结尾,且 --json,则保存为 JSON 文件  
347 - with open(args.output, "w", encoding="utf-8-sig") as f:  
348 - f.write(json_str)  
349 - f.write("\n")  
350 - print(f"JSON 结果已保存到: {args.output}")  
351 - elif args.json:  
352 - print(json_str)  
353 - else:  
354 - print(f"\n{'='*50}")  
355 - print("SEI UTC 信息提取结果")  
356 - print(f"{'='*50}")  
357 - print(f"mp4 文件 : {result['mp4_path']}")  
358 - print(f"SEI 消息总数 : {result['sei_count']}")  
359 - print(f"UTC 记录数 : {len(utc_records)}")  
360 - if utc_records:  
361 - print(f"\nUTC 详细信息:")  
362 - for idx, rec in enumerate(utc_records, 1):  
363 - print(f" [{idx}] utc={rec['utc']}, origin_utc={rec.get('origin_utc')}, origin_offset={rec.get('origin_offset')}")  
364 - else:  
365 - print("\n未从 SEI 中提取到 UTC 信息。")  
366 - print(f"{'='*50}")  
367 -  
368 - except Exception as e:  
369 - print(f"错误: {e}", file=sys.stderr)  
370 - sys.exit(1)  
371 -  
372 -  
373 -if __name__ == "__main__":  
374 - main()  
375 -  
376 -# python mp4_sei_extractor.py "3b3e99cf4ca84c3782503d8817242de2.mp4" --json -o sei_result.json  
1 import json 1 import json
2 import os.path 2 import os.path
3 from pathlib import Path 3 from pathlib import Path
  4 +from datetime import timedelta
4 5
5 from langchain_core.messages import SystemMessage, HumanMessage 6 from langchain_core.messages import SystemMessage, HumanMessage
6 from langchain_openai import ChatOpenAI 7 from langchain_openai import ChatOpenAI
7 8
8 try: 9 try:
9 - from .llm_video_content import contents as video_contents 10 + from .llm_image import Video2Frame
10 except: 11 except:
11 - from llm_video_content import contents as video_contents 12 + from llm_image import Video2Frame
12 13
13 req_prompt = """ 14 req_prompt = """
14 # Role 15 # Role
@@ -70,7 +71,7 @@ req_prompt = """ @@ -70,7 +71,7 @@ req_prompt = """
70 71
71 72
72 class FootballReplayMatchLive: 73 class FootballReplayMatchLive:
73 - def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key'): 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):
74 self.base_url = base_url 75 self.base_url = base_url
75 self.model = model 76 self.model = model
76 self.temperature = temperature 77 self.temperature = temperature
@@ -82,12 +83,15 @@ class FootballReplayMatchLive: @@ -82,12 +83,15 @@ class FootballReplayMatchLive:
82 self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, 83 self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key,
83 extra_body={"chat_template_kwargs": {"enable_thinking": False}}) 84 extra_body={"chat_template_kwargs": {"enable_thinking": False}})
84 85
85 - def _match_once(self, replay_video_contents: list, live_videos: list[dict], cache_path=None, record: list = None): 86 + self.cache_dir = cache_dir
  87 + self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable)
  88 +
  89 + def _match_once(self, replay_video_contents: list, live_videos: list[dict], record: list = None):
86 90
87 if len(live_videos) == 0: 91 if len(live_videos) == 0:
88 return None 92 return None
89 elif len(live_videos) == 1: 93 elif len(live_videos) == 1:
90 - live_video_path = live_videos[0].get("video_path", None) 94 + live_video_path = live_videos[0].get("url", None)
91 live_video_id = live_videos[0].get("video_id", os.path.basename(live_video_path)) 95 live_video_id = live_videos[0].get("video_id", os.path.basename(live_video_path))
92 asr_text = live_videos[0].get("asr_text", '') 96 asr_text = live_videos[0].get("asr_text", '')
93 live = { 97 live = {
@@ -107,16 +111,24 @@ class FootballReplayMatchLive: @@ -107,16 +111,24 @@ class FootballReplayMatchLive:
107 live_map = {} 111 live_map = {}
108 live_records = {} 112 live_records = {}
109 for live_video in live_videos: 113 for live_video in live_videos:
110 - live_video_path = live_video.get("video_path", None) 114 + live_video_path = live_video.get("url", None)
111 live_video_id = live_video.get("video_id", os.path.basename(live_video_path)) 115 live_video_id = live_video.get("video_id", os.path.basename(live_video_path))
  116 + event_utc = live_video.get("event_utc", None)
112 asr_text = live_video.get("asr_text", '') 117 asr_text = live_video.get("asr_text", '')
113 live_video_contents = live_video.get("llm_contents", None) 118 live_video_contents = live_video.get("llm_contents", None)
114 if live_video_contents is None: 119 if live_video_contents is None:
115 - live_video_contents = video_contents(live_video_path, 120 + event_start = event_utc - timedelta(seconds=10)
  121 + event_end = event_utc + timedelta(seconds=5)
  122 + live_video_contents = self.video2frame.to_llm_contents( live_video_path,
  123 + cache=self.cache_dir,
  124 + fps=2,
  125 + start=event_start,
  126 + end=event_end,
  127 + roi=None,
  128 + max_px_area=400_000,
116 prompt_start=f"### 候选片段 video_id: {live_video_id} ###", 129 prompt_start=f"### 候选片段 video_id: {live_video_id} ###",
117 - prompt_end=f"\n该片段解说内容: {asr_text}\n",  
118 - video_name=os.path.basename(live_video_path),  
119 - fps=2, max_frames=999, sampling_mode="head", max_short_edge=480) 130 + prompt_end=f"\n该片段解说内容: {asr_text}\n"
  131 + )
120 live_map[live_video_id] = { 132 live_map[live_video_id] = {
121 "video_id": live_video_id, 133 "video_id": live_video_id,
122 "video_path": live_video_path, 134 "video_path": live_video_path,
@@ -193,17 +205,29 @@ class FootballReplayMatchLive: @@ -193,17 +205,29 @@ class FootballReplayMatchLive:
193 else: 205 else:
194 return None 206 return None
195 207
196 - def match_batch(self, replay_video: dict, live_videos: list[dict], max_parallel: int = 3, cache_path=None): 208 + def match_batch(self, replay_video: dict, live_videos: list[dict], max_parallel: int = 3, cache_dir=None):
  209 +
  210 + cache_path = os.path.join(cache_dir, 'match_live.json')
197 if cache_path is not None and os.path.exists(cache_path): 211 if cache_path is not None and os.path.exists(cache_path):
198 try: 212 try:
199 with open(cache_path, 'r', encoding='utf-8') as f: 213 with open(cache_path, 'r', encoding='utf-8') as f:
200 return json.loads(f.read()).get("result", None) 214 return json.loads(f.read()).get("result", None)
201 except: 215 except:
202 os.remove(cache_path) 216 os.remove(cache_path)
203 - replay_video_contents = video_contents(replay_video["video_path"], "\n【回放片段信息】\n",  
204 - prompt_end=f"\n回放解说内容:{replay_video['asr_text']}\n",  
205 - video_name=os.path.basename(replay_video["video_path"]),  
206 - fps=2, max_frames=999, sampling_mode="head", max_short_edge=480) 217 +
  218 + replay_video_path = replay_video.get("url", None)
  219 + event_start = replay_video.get("start_utc", None)
  220 + event_end = replay_video.get("end_utc", None)
  221 + replay_video_contents = self.video2frame.to_llm_contents(replay_video_path,
  222 + cache=self.cache_dir,
  223 + fps=2,
  224 + start=event_start,
  225 + end=event_end,
  226 + roi=None,
  227 + max_px_area=400_000,
  228 + prompt_start="\n【回放片段信息】\n",
  229 + prompt_end=f"\n回放解说内容:无\n"
  230 + )
207 live_record = [] 231 live_record = []
208 result = self._match_batch(replay_video_contents, live_videos, max_parallel, cache_path, live_record) 232 result = self._match_batch(replay_video_contents, live_videos, max_parallel, cache_path, live_record)
209 if result is not None: 233 if result is not None:
@@ -6,9 +6,10 @@ from langchain_core.messages import SystemMessage, HumanMessage @@ -6,9 +6,10 @@ from langchain_core.messages import SystemMessage, HumanMessage
6 from langchain_openai import ChatOpenAI 6 from langchain_openai import ChatOpenAI
7 7
8 try: 8 try:
9 - from .llm_video_content import contents as video_contents 9 + from .llm_image import Video2Frame
10 except: 10 except:
11 - from llm_video_content import contents as video_contents 11 + from llm_image import Video2Frame
  12 +
12 13
13 req_prompt = """ 14 req_prompt = """
14 ### 角色设定 15 ### 角色设定
@@ -65,7 +66,7 @@ req_prompt = """ @@ -65,7 +66,7 @@ req_prompt = """
65 66
66 67
67 class FootballReplayVideoEvent: 68 class FootballReplayVideoEvent:
68 - def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key'): 69 + 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):
69 self.base_url = base_url 70 self.base_url = base_url
70 self.model = model 71 self.model = model
71 self.temperature = temperature 72 self.temperature = temperature
@@ -77,31 +78,34 @@ class FootballReplayVideoEvent: @@ -77,31 +78,34 @@ class FootballReplayVideoEvent:
77 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,
78 extra_body={"chat_template_kwargs": {"enable_thinking": False}}) 79 extra_body={"chat_template_kwargs": {"enable_thinking": False}})
79 80
80 - def video_event(self, video_path: str, asr_text: str = '无', cache_path=None):  
81 - if cache_path is not None and os.path.exists(cache_path):  
82 - with open(cache_path, 'r', encoding='utf-8') as f:  
83 - return json.loads(f.read()) 81 + self.cache_dir = cache_dir
  82 + self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable)
84 83
85 - contents = video_contents(video_path, None, video_name=os.path.basename(video_path),  
86 - fps=2, max_frames=999, sampling_mode="head", max_short_edge=480)  
87 - system_message = SystemMessage(content=req_prompt)  
88 - video_message = HumanMessage(content=contents)  
89 - asr_message = HumanMessage(content=f"解说内容:{asr_text}")  
90 - result = self.model.invoke([system_message, video_message, asr_message]).content  
91 - try:  
92 - result_json = json.loads(result)  
93 - except json.JSONDecodeError:  
94 - result_json = json.loads(result.replace("```json", "").replace("```", ""))  
95 - if cache_path is not None:  
96 - os.makedirs(Path(cache_path).parent, exist_ok=True)  
97 - with open(cache_path, 'w', encoding='utf-8') as f:  
98 - f.write(result)  
99 - return result_json 84 + def video_event(self, replay_pack: dict, asr_text: str = '无', cache_dir=None):
  85 + replay_video_path = replay_pack.get("url", None)
100 86
101 - def video_event_for_contents(self, contents: list, asr_text: str = '无', cache_path=None): 87 + cache_path = os.path.join(cache_dir, 'video_event.json')
102 if cache_path is not None and os.path.exists(cache_path): 88 if cache_path is not None and os.path.exists(cache_path):
103 with open(cache_path, 'r', encoding='utf-8') as f: 89 with open(cache_path, 'r', encoding='utf-8') as f:
104 return json.loads(f.read()) 90 return json.loads(f.read())
  91 +
  92 + # contents = video_contents(video_path, None, video_name=os.path.basename(video_path),
  93 + # fps=2, max_frames=999, sampling_mode="head", max_short_edge=480)
  94 +
  95 +
  96 + event_start = replay_pack.get("start_utc", None)
  97 + event_end = replay_pack.get("end_utc", None)
  98 + contents = self.video2frame.to_llm_contents(replay_video_path,
  99 + cache=self.cache_dir,
  100 + fps=2,
  101 + start=event_start,
  102 + end=event_end,
  103 + roi=None,
  104 + max_px_area=400_000,
  105 + prompt_start="\n【回放片段信息】\n",
  106 + prompt_end=f"\n回放解说内容:{asr_text}\n"
  107 + )
  108 +
105 system_message = SystemMessage(content=req_prompt) 109 system_message = SystemMessage(content=req_prompt)
106 video_message = HumanMessage(content=contents) 110 video_message = HumanMessage(content=contents)
107 asr_message = HumanMessage(content=f"解说内容:{asr_text}") 111 asr_message = HumanMessage(content=f"解说内容:{asr_text}")
@@ -115,3 +119,24 @@ class FootballReplayVideoEvent: @@ -115,3 +119,24 @@ class FootballReplayVideoEvent:
115 with open(cache_path, 'w', encoding='utf-8') as f: 119 with open(cache_path, 'w', encoding='utf-8') as f:
116 f.write(result) 120 f.write(result)
117 return result_json 121 return result_json
  122 +
  123 +
  124 +if __name__ == "__main__":
  125 + from aabd.base.patched_logging import init_logging
  126 + import os
  127 + os.environ["APP_LOG_TYPE"] = "console"
  128 + init_logging()
  129 + fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1",
  130 + model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf",
  131 + temperature=0.7,
  132 + cache_dir="/root/lzw/tmp_0518_replay_cache",
  133 + save_frames_enable=True
  134 + )
  135 + replay_pack = {"url": "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8",
  136 + "start_utc": 0,
  137 + "end_utc": 999999999,
  138 + "asr_text": '无'
  139 + }
  140 +
  141 + replay_goals = fbrv.video_event(replay_pack, cache_path="/root/lzw/tmp_0518_replay_cache")
  142 + print(replay_goals)
@@ -153,11 +153,13 @@ if __name__ == '__main__': @@ -153,11 +153,13 @@ if __name__ == '__main__':
153 import os 153 import os
154 os.environ["APP_LOG_TYPE"] = "console" 154 os.environ["APP_LOG_TYPE"] = "console"
155 init_logging() 155 init_logging()
156 - url = "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8"  
157 - # url = rf"R:\wdx\202604\20260420_download_football_video\finished\69dd5845dd0412067b8d5587-auto-1776074760653\live\videos\00-14-09-052.mp4"  
158 -  
159 - vf = Video2Frame(rf'D:\Code\migu\aigc-embedding-service\src\football_replay_match\core', save_frames_enable=True)  
160 - a = vf.to_llm_contents(url, fps=2, start=1777444502543, end=1777444531063, max_px_area=1920 * 1080 // 6) 156 + # url = "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8"
  157 + url = rf"/root/lzw/finished/69dd5845dd0412067b8d5587-auto-1776074760653/live/videos/00-14-09-052.mp4"
  158 + caceh_dir = r"/root/lzw/aigc-embedding-service/src/football_replay_match/core"
  159 + vf = Video2Frame(caceh_dir, save_frames_enable=True)
  160 + # vf = Video2Frame(rf'D:\Code\migu\aigc-embedding-service\src\football_replay_match\core', save_frames_enable=True)
  161 + # a = vf.to_llm_contents(url, fps=2, start=1777444502543, end=1777444531063, max_px_area=1920 * 1080 // 6)
  162 + a = vf.to_llm_contents(url, fps=2, start=0, end=10, max_px_area=1920 * 1080 // 6)
161 from langchain_openai import ChatOpenAI 163 from langchain_openai import ChatOpenAI
162 from langchain_core.messages import SystemMessage, HumanMessage 164 from langchain_core.messages import SystemMessage, HumanMessage
163 165
1 -import base64  
2 -import tempfile  
3 -from pathlib import Path  
4 -from typing import Union  
5 -import cv2  
6 -import httpx  
7 -  
8 -  
9 -def _resize_frame(frame, max_short_edge: int):  
10 - """按比例缩放帧,确保最短边不超过指定值"""  
11 - h, w = frame.shape[:2]  
12 - short_edge = min(h, w)  
13 - if short_edge > max_short_edge:  
14 - scale = max_short_edge / short_edge  
15 - new_w = int(w * scale)  
16 - new_h = int(h * scale)  
17 - frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA)  
18 - return frame  
19 -  
20 -  
21 -def contents(video_source: Union[str, Path], prompt_start: str = None, prompt_end=None, video_name: str = None,  
22 - fps: float = 1.0,  
23 - max_frames: int = 10,  
24 - max_short_edge: int = 768,  
25 - sampling_mode: str = "uniform") -> list[str]:  
26 - """  
27 - 从视频中提取帧并构建 LLM 消息内容。  
28 -  
29 - Args:  
30 - video_source: 视频源,可以是文件路径或 URL。  
31 - prompt_start: 提示词的开始部分。  
32 - prompt_end: 提示词的结束部分。  
33 - video_name: 视频名称。  
34 - fps: 提取帧的帧率。  
35 - max_frames: 最大帧数。  
36 - max_short_edge: 最大短边长度。  
37 - sampling_mode: 当帧数超过 max_frames 时的采样策略。  
38 - - "uniform": 均匀采样(默认)  
39 - - "head": 保留前面的帧,抛弃后面的帧  
40 - """  
41 - source_str = str(video_source)  
42 - temp_file = None  
43 -  
44 - try:  
45 - # 如果是 URL,先下载到临时文件  
46 - if source_str.startswith(("http://", "https://")):  
47 - resp = httpx.get(source_str, timeout=60.0)  
48 - resp.raise_for_status()  
49 - temp_file = tempfile.NamedTemporaryFile(suffix=".mp4", delete=False)  
50 - temp_file.write(resp.content)  
51 - temp_file.close()  
52 - video_path = temp_file.name  
53 - else:  
54 - video_path = str(video_source)  
55 - if not Path(video_path).exists():  
56 - raise FileNotFoundError(f"视频文件不存在: {video_path}")  
57 -  
58 - # 打开视频  
59 - cap = cv2.VideoCapture(video_path)  
60 - if not cap.isOpened():  
61 - raise ValueError(f"无法打开视频: {video_source}")  
62 -  
63 - # 获取视频原始帧率  
64 - video_fps = cap.get(cv2.CAP_PROP_FPS)  
65 -  
66 - # 计算帧间隔  
67 - frame_interval = int(video_fps / fps) if fps > 0 else int(video_fps)  
68 - if frame_interval < 1:  
69 - frame_interval = 1  
70 -  
71 - frames_base64 = []  
72 - frame_count = 0  
73 -  
74 - while True:  
75 - ret, frame = cap.read()  
76 - if not ret:  
77 - break  
78 -  
79 - # 按指定间隔提取帧  
80 - if frame_count % frame_interval == 0:  
81 - # 缩放帧以控制最短边长度  
82 - frame = _resize_frame(frame, max_short_edge)  
83 - # 编码为 JPEG 并转 base64  
84 - _, buffer = cv2.imencode('.jpg', frame)  
85 - frame_base64 = base64.b64encode(buffer).decode('utf-8')  
86 - frames_base64.append(frame_base64)  
87 -  
88 - frame_count += 1  
89 -  
90 - cap.release()  
91 -  
92 - if len(frames_base64) > max_frames:  
93 - import math  
94 - step = len(frames_base64) / max_frames  
95 - sampled = [frames_base64[min(int(i * step), len(frames_base64) - 1)] for i in range(max_frames)]  
96 - frames_base64 = sampled  
97 -  
98 - # 构建消息内容:提示词 + 所有帧图片  
99 - video_prompt = (  
100 - f"以下是从视频({video_name})中按时间顺序提取的 {len(frames_base64)} 帧画面,视频原始帧率为 {video_fps:.2f} fps,"  
101 - f"抽帧间隔为 {frame_interval} 帧(约每 {frame_interval / video_fps:.2f} 秒一帧),请将它们视为一个连续的视频进行分析。"  
102 - )  
103 - content = []  
104 - if prompt_start is not None:  
105 - content.append({"type": "text", "text": prompt_start})  
106 - content.append({"type": "text", "text": video_prompt})  
107 -  
108 - for frame_base64 in frames_base64:  
109 - content.append({  
110 - "type": "image_url",  
111 - "image_url": {  
112 - "url": f"data:image/jpeg;base64,{frame_base64}"  
113 - }  
114 - })  
115 - if prompt_end is not None:  
116 - content.append({"type": "text", "text": prompt_end})  
117 - return content  
118 -  
119 - finally:  
120 - # 清理临时文件  
121 - if temp_file and Path(temp_file.name).exists():  
122 - Path(temp_file.name).unlink()  
1 -from football_replay_match_live import FootballReplayMatchLive  
2 -from qwen_asr_util import QwenAsr  
3 -from football_replay_video_event_by_llm import FootballReplayVideoEvent  
4 -import os  
5 -import json  
6 -  
7 -  
8 -def batch_match():  
9 - replay_match_live = FootballReplayMatchLive(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf")  
10 - qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B")  
11 - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7)  
12 -  
13 - videos_dir = rf"R:\wdx\202604\20260420_download_football_video\finished"  
14 - for video_name in sorted(os.listdir(videos_dir)):  
15 -  
16 - live_dir = os.path.join(videos_dir, video_name, 'live')  
17 - live_video_dir = os.path.join(live_dir, 'videos')  
18 - live_asr_dir = os.path.join(live_dir, 'asr')  
19 -  
20 - live_packs = []  
21 - for live_video_name in sorted(os.listdir(live_video_dir)):  
22 - live_video_path = os.path.join(live_video_dir, live_video_name)  
23 - live_asr_path = os.path.join(live_asr_dir, f"{live_video_name}.txt")  
24 - # live_asr_text = qwen_asr.asr(live_video_path, cache_path=live_asr_path)  
25 - live_asr_text = ''  
26 - live_packs.append({  
27 - "video_id": os.path.basename(live_video_path),  
28 - "video_path": live_video_path,  
29 - "asr_text": live_asr_text  
30 - })  
31 -  
32 - replays_dir = os.path.join(videos_dir, video_name, 'replays')  
33 - replay_videos_dir = os.path.join(replays_dir, 'videos')  
34 - replay_goals_dir = os.path.join(replays_dir, 'goals')  
35 - replay_asr_dir = os.path.join(replays_dir, 'asr')  
36 - replay_matches_dir = os.path.join(replays_dir, 'matches')  
37 -  
38 - for replay_video_name in sorted(os.listdir(replay_videos_dir)):  
39 - replay_video_path = os.path.join(replay_videos_dir, replay_video_name)  
40 - replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json")  
41 -  
42 - replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.txt")  
43 - replay_match_path = os.path.join(replay_matches_dir, f"{replay_video_name}.json")  
44 -  
45 - # replay_asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path)  
46 - replay_asr_text = ''  
47 -  
48 - replay_goals = fbrv.video_event(replay_video_path, cache_path=replay_goals_path)  
49 - if replay_goals['event_name'] == '无进球':  
50 - continue  
51 - replay_pack = {"video_path": replay_video_path, "asr_text": replay_asr_text}  
52 -  
53 - try:  
54 - result = replay_match_live.match_batch(replay_pack, live_packs, max_parallel=2,  
55 - cache_path=replay_match_path)  
56 - print(replay_video_path)  
57 - print(result)  
58 -  
59 - except Exception as e:  
60 - print(f"Error processing {replay_video_path}: {e}")  
61 -  
62 - print('-' * 20)  
63 -  
64 -  
65 -if __name__ == '__main__':  
66 - batch_match()  
1 -import os  
2 -from io import BytesIO  
3 -import re  
4 -import requests  
5 -import base64  
6 -import tempfile  
7 -from pathlib import Path  
8 -from typing import Union  
9 -import httpx  
10 -import subprocess  
11 -  
12 -from langchain_core.messages import HumanMessage  
13 -from langchain_openai import ChatOpenAI  
14 -  
15 -  
16 -class QwenAsr:  
17 - def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key'):  
18 - self.base_url = base_url  
19 - self.model = model  
20 - self.temperature = temperature  
21 - self.api_key = api_key  
22 - # self.asr_model = ChatOpenAI(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B", temperature=0.7,  
23 - # api_key='no_key')  
24 - self.asr_model = ChatOpenAI(base_url=self.base_url, model=self.model, temperature=self.temperature,  
25 - api_key=self.api_key)  
26 -  
27 - @staticmethod  
28 - def _audio_content(source: Union[str, Path]) -> list:  
29 - """  
30 - 读取音频/视频文件并构建 LLM 消息内容。  
31 - 如果 source 是视频,会先提取音频;如果是 HTTP 音频 URL,直接引用 URL。  
32 -  
33 - Args:  
34 - source: 音频/视频文件路径或 URL  
35 - Returns:  
36 - list: LLM 消息内容列表  
37 - """  
38 - source_str = str(source)  
39 - audio_exts = {"wav", "mp3", "m4a", "flac", "ogg", "webm", "aac", "wma"}  
40 - video_exts = {"mp4", "avi", "mkv", "mov", "wmv", "flv", "webm", "mpeg", "mpg", "ts", "3gp", "m4v"}  
41 - temp_files = []  
42 -  
43 - try:  
44 - content = []  
45 - is_url = source_str.startswith(("http://", "https://"))  
46 - ext = Path(source_str).suffix.lstrip(".").lower()  
47 -  
48 - # HTTP 音频 URL:直接引用,无需下载  
49 - if is_url and ext in audio_exts:  
50 - # audio_prompt = f"以下是音频文件({audio_name})的内容,请进行分析。"  
51 - # content = [{"type": "text", "text": audio_prompt}]  
52 - # if prompt is not None:  
53 - # content.append({"type": "text", "text": prompt})  
54 - content.append({  
55 - "type": "audio_url",  
56 - "audio_url": {  
57 - "url": source_str  
58 - }  
59 - })  
60 - return content  
61 -  
62 - # 本地文件或需要下载的 URL(视频或其他媒体)  
63 - if is_url:  
64 - resp = httpx.get(source_str, timeout=60.0)  
65 - resp.raise_for_status()  
66 - suffix = Path(source_str).suffix or ".mp4"  
67 - temp_file = tempfile.NamedTemporaryFile(suffix=suffix, delete=False)  
68 - temp_file.write(resp.content)  
69 - temp_file.close()  
70 - temp_files.append(temp_file.name)  
71 - media_path = temp_file.name  
72 - else:  
73 - media_path = str(source)  
74 - if not Path(media_path).exists():  
75 - raise FileNotFoundError(f"文件不存在: {media_path}")  
76 -  
77 - media_ext = Path(media_path).suffix.lstrip(".").lower()  
78 -  
79 - # 如果是视频,提取音频为 wav  
80 - if media_ext in video_exts or (is_url and ext not in audio_exts):  
81 - temp_audio = tempfile.NamedTemporaryFile(suffix=".wav", delete=False)  
82 - temp_audio.close()  
83 - temp_files.append(temp_audio.name)  
84 - cmd = [  
85 - "ffmpeg",  
86 - "-y",  
87 - "-i", media_path,  
88 - "-vn",  
89 - "-acodec", "pcm_s16le",  
90 - "-ar", "16000",  
91 - "-ac", "1",  
92 - "-hide_banner",  
93 - "-loglevel", "error",  
94 - temp_audio.name  
95 - ]  
96 - result = subprocess.run(cmd, capture_output=True, text=True)  
97 - if result.returncode != 0:  
98 - raise RuntimeError(f"ffmpeg 提取音频失败: {result.stderr.strip()}")  
99 - audio_path = temp_audio.name  
100 - media_ext = "wav"  
101 - else:  
102 - audio_path = media_path  
103 - if media_ext not in audio_exts:  
104 - media_ext = "wav"  
105 -  
106 - # 读取音频并 base64 编码  
107 - with open(audio_path, "rb") as f:  
108 - audio_bytes = f.read()  
109 - audio_base64 = base64.b64encode(audio_bytes).decode("utf-8")  
110 -  
111 - # 构建消息内容  
112 - # audio_prompt = f"以下是音频文件({audio_name})的内容,请进行分析。"  
113 - # content = [{"type": "text", "text": audio_prompt}]  
114 - # if prompt is not None:  
115 - # content.append({"type": "text", "text": prompt})  
116 -  
117 - content.append({  
118 - "type": "audio_url",  
119 - "audio_url": {  
120 - "url": f"data:audio/{media_ext};base64,{audio_base64}"  
121 - }  
122 - })  
123 -  
124 - return content  
125 - finally:  
126 - # 清理所有临时文件  
127 - for tf in temp_files:  
128 - p = Path(tf)  
129 - if p.exists():  
130 - p.unlink()  
131 -  
132 - @staticmethod  
133 - def _extract_asr_text(text: str) -> str:  
134 - """  
135 - 从ASR响应文本中提取<asr_text>标签内容  
136 -  
137 - Args:  
138 - text: 原始响应文本,如 'language Chinese<asr_text>放大一点一倍。'  
139 -  
140 - Returns:  
141 - str: 提取的转录文本,如 '放大一点一倍。'  
142 - """  
143 - # 尝试匹配 <asr_text>...</asr_text>  
144 - match = re.search(r'<asr_text>(.*?)(?:</asr_text>|$)', text, re.DOTALL)  
145 - if match:  
146 - return match.group(1).strip()  
147 - return text  
148 -  
149 - def asr(self, source, cache_path:str=None):  
150 - if cache_path is not None and os.path.exists(cache_path):  
151 - with open(cache_path, 'r', encoding='utf-8') as f:  
152 - return f.read()  
153 - contents = self._audio_content(source)  
154 - message = HumanMessage(content=contents)  
155 - result = self.asr_model.invoke([message])  
156 - asr_text = self._extract_asr_text(result.content)  
157 - if cache_path is not None:  
158 - os.makedirs(Path(cache_path).parent, exist_ok=True)  
159 - with open(cache_path, 'w', encoding='utf-8') as f:  
160 - f.write(asr_text)  
161 - return asr_text  
1 -import json  
2 -import os  
3 -  
4 -from football_replay_video_event_by_llm import FootballReplayVideoEvent  
5 -from qwen_asr_util import QwenAsr  
6 -def batch_with_asr():  
7 - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7)  
8 - qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B",temperature=0.7,  
9 - api_key='no_key')  
10 -  
11 - videos_dir = rf"R:\wdx\202604\20260420_download_football_video\finished"  
12 - for video_name in sorted(os.listdir(videos_dir)):  
13 - replays_dir = os.path.join(videos_dir, video_name, 'replays')  
14 - replay_videos_dir = os.path.join(replays_dir, 'videos')  
15 - replay_goals_dir = os.path.join(replays_dir, 'goals')  
16 - replay_asr_dir = os.path.join(replays_dir, 'asr')  
17 -  
18 - for replay_video_name in sorted(os.listdir(replay_videos_dir)):  
19 - replay_video_path = os.path.join(replay_videos_dir, replay_video_name)  
20 - replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json")  
21 - replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.json")  
22 - if os.path.exists(replay_goals_path):  
23 - print("skip", replay_video_path)  
24 - continue  
25 - asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path)  
26 - event_json = fbrv.video_event(replay_video_name, asr_text)  
27 - print(event_json)  
28 -  
29 -def batch_without_asr():  
30 - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7)  
31 - # qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B", temperature=0.7,  
32 - # api_key='no_key')  
33 -  
34 - videos_dir = rf"R:\wdx\202604\20260420_download_football_video\finished"  
35 - for video_name in sorted(os.listdir(videos_dir)):  
36 - replays_dir = os.path.join(videos_dir, video_name, 'replays')  
37 - replay_videos_dir = os.path.join(replays_dir, 'videos')  
38 - replay_goals_dir = os.path.join(replays_dir, 'goals')  
39 - replay_asr_dir = os.path.join(replays_dir, 'asr')  
40 -  
41 - for replay_video_name in sorted(os.listdir(replay_videos_dir)):  
42 - replay_video_path = os.path.join(replay_videos_dir, replay_video_name)  
43 - replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json")  
44 - replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.json")  
45 - if os.path.exists(replay_goals_path):  
46 - print("skip", replay_video_path)  
47 - continue  
48 - # asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path)  
49 - event_json = fbrv.video_event(replay_video_path, None, cache_path=replay_goals_path)  
50 - print(replay_video_name)  
51 - print(event_json)  
52 -  
53 -def one_video_test(video_path):  
54 - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7)  
55 - event_json = fbrv.video_event(video_path, None)  
56 - print(event_json)  
57 -  
58 -if __name__ == '__main__':  
59 - # one_video_test(rf"D:\Code\py260417\src\llm_demo\lc_t\ftb\replay_1.mp4")  
60 - batch_without_asr()  
1 -import os  
2 -import json  
3 -from pathlib import Path  
4 -  
5 -from aabd.base.time_util import vms2str_auto  
6 -from langchain_openai import ChatOpenAI  
7 -  
8 -  
9 -class ClipByEvent:  
10 - def __init__(self, base_url, model, temperature=0.0, api_key='no_key'):  
11 - self.base_url = base_url  
12 - self.model = model  
13 - self.temperature = temperature  
14 - self.api_key = api_key  
15 - self.clip_before = 10 * 1000  
16 - self.clip_after = 5 * 1000  
17 -  
18 - # self.model = ChatOllama(base_url="http://192.168.1.59:11434", model="qwen3.6:35b-a3b-q8_0", temperature=0.7,  
19 - # keep_alive=-1, reasoning=False)  
20 - # self.model = ChatOpenAI(base_url="http://192.168.1.59:11434/v1", model="qwen3.6:35b-a3b-q8_0", temperature=0.7,  
21 - # api_key='no_key')  
22 - self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key)  
23 -  
24 - def get_video_match_time(self, video_path, video_time):  
25 - import cv2  
26 - import base64  
27 - from langchain_core.messages import HumanMessage  
28 -  
29 - # 从视频中精确提取指定时间的帧画面  
30 - cap = cv2.VideoCapture(video_path)  
31 - if not cap.isOpened():  
32 - raise ValueError(f"无法打开视频文件: {video_path}")  
33 -  
34 - fps = cap.get(cv2.CAP_PROP_FPS)  
35 - # video_time 单位为毫秒,转换为帧号  
36 - frame_number = int((video_time / 1000.0) * fps)  
37 - cap.set(cv2.CAP_PROP_POS_FRAMES, frame_number)  
38 - ret, frame = cap.read()  
39 - cap.release()  
40 -  
41 - if not ret:  
42 - raise ValueError(f"无法从视频 {video_path} 中提取时间 {video_time}ms 的帧画面")  
43 -  
44 - # 将帧图像编码为base64  
45 - _, buffer = cv2.imencode('.jpg', frame)  
46 - image_base64 = base64.b64encode(buffer).decode('utf-8')  
47 -  
48 - # 使用大模型分析比赛画面中的时间  
49 - message = HumanMessage(  
50 - content=[  
51 - {  
52 - "type": "text",  
53 - "text": "请仔细观察这张比赛画面截图,找出画面中显示的比赛计时器或时间信息,并以'MM:SS'格式返回比赛时间。如果无法识别,请返回'unknown'。"  
54 - },  
55 - {  
56 - "type": "image_url",  
57 - "image_url": {  
58 - "url": f"data:image/jpeg;base64,{image_base64}"  
59 - }  
60 - }  
61 - ]  
62 - )  
63 - response = self.model.invoke([message])  
64 - match_time_text = response.content.strip()  
65 -  
66 - if match_time_text == 'unknown':  
67 - raise ValueError(f"无法识别视频 {video_path} 中时间 {video_time}ms 的比赛时间")  
68 - return match_time_text  
69 -  
70 - def clip_video(self, video_path, clip_time, out_path):  
71 - # 精确截取视频指定视频段,从新编码  
72 -  
73 - # 毫秒  
74 - start_time = clip_time - self.clip_before  
75 - end_time = clip_time + self.clip_after  
76 -  
77 - start_time_s = start_time / 1000.0  
78 - duration_s = (end_time - start_time) / 1000.0  
79 -  
80 - import subprocess  
81 - import os  
82 -  
83 - os.makedirs(os.path.dirname(out_path), exist_ok=True)  
84 -  
85 - cmd = [  
86 - 'ffmpeg',  
87 - '-i', video_path,  
88 - '-ss', str(start_time_s),  
89 - '-t', str(duration_s),  
90 - '-c:v', 'libx264',  
91 - '-c:a', 'aac',  
92 - '-y',  
93 - out_path  
94 - ]  
95 -  
96 - subprocess.run(cmd, check=True)  
97 -  
98 - def clip_by_event(self, video_dir, json_dir, out_dir):  
99 -  
100 - for video_name in sorted(os.listdir(video_dir)):  
101 - video_path = os.path.join(video_dir, video_name)  
102 - f_name_0 = Path(video_name).stem  
103 - json_path = os.path.join(json_dir, f"{f_name_0}.json")  
104 - with open(json_path, 'r', encoding='utf-8') as f:  
105 - data = json.load(f)  
106 -  
107 - events = data['dataObject']['data']['events']  
108 -  
109 - event0 = data['dataObject']['data']['events'][0]  
110 - event0_sei_utc = event0['seiUtc']  
111 - event0_text_time = event0['eventTimeText']  
112 - mm, ss = map(int, event0_text_time.split(':'))  
113 - event0_time_seconds = mm * 60 + ss  
114 - event0_time_ms = event0_time_seconds * 1000  
115 - match_start_sei_utc = event0_sei_utc - event0_time_ms  
116 -  
117 - take_frame_time = 30 * 60 * 1000  
118 - match_text_time = self.get_video_match_time(video_path, take_frame_time)  
119 - mm, ss = map(int, match_text_time.split(':'))  
120 - match_time_seconds = mm * 60 + ss  
121 - match_time_ms = match_time_seconds * 1000  
122 - match_start_video_time = take_frame_time - match_time_ms  
123 -  
124 - video_start_sei_utc = match_start_sei_utc - match_start_video_time  
125 -  
126 - start_utc = video_start_sei_utc  
127 -  
128 - os.makedirs(os.path.join(out_dir, f_name_0), exist_ok=True)  
129 -  
130 - # 样例  
131 - # 41 传球 曼联,约罗 00:36 1776106869512 638160 00:10:38.160  
132 - # 41 传球 曼联,马兹拉维 00:39 1776106872512 641160 00:10:41.160  
133 - event_list = []  
134 - for event in events:  
135 - event_enum = event['eventType']  
136 - eventTitle = event['eventTitle']  
137 - event_array = eventTitle.split(' ')  
138 - event_2 = event_array[-1]  
139 - event_1 = ','.join(event_array[:-1])  
140 - eventTimeText = event['eventTimeText']  
141 - seiUtc = event['seiUtc']  
142 - event_list.append([event_enum, event_2, event_1, eventTimeText, seiUtc, seiUtc - start_utc,  
143 - vms2str_auto(seiUtc - start_utc)])  
144 - # out_f.write(  
145 - # f'{event_enum}\t{event_2}\t{event_1}\t{eventTimeText}\t{seiUtc}\t{seiUtc - start_utc}\t{vms2str_auto(seiUtc - start_utc)}\n')  
146 -  
147 - with open(os.path.join(out_dir, f_name_0, f"sport_events.txt"), 'w', encoding='utf-8') as out_f:  
148 - for event in event_list:  
149 - out_f.write(  
150 - f'{event[0]}\t{event[1]}\t{event[2]}\t{event[3]}\t{event[4]}\t{event[5]}\t{event[6]}\n')  
151 -  
152 - for event in event_list:  
153 - if event[1] == '进球':  
154 - out_path = os.path.join(out_dir, f_name_0, 'videos',  
155 - f"{event[6].replace(':', '-').replace('.', '-')}.mp4")  
156 -  
157 - if not os.path.exists(out_path):  
158 - print(f"Clipping {video_name} {event[6]} to {out_path}")  
159 - self.clip_video(video_path, event[5], out_path)  
160 - else:  
161 - print(f"Clip skip {video_name} {event[6]} as {out_path} already exists")  
162 -  
163 -  
164 -if __name__ == '__main__':  
165 - cbe = ClipByEvent(base_url="http://192.168.1.215:11434", model="qwen3.5:9b-q8_0", temperature=0.7)  
166 - cbe.clip_by_event(video_dir=rf"L:\wdx\202604\20260420_download_football_video\downloads",  
167 - json_dir=rf"D:\Code\py260417\src\llm_demo\foot_event_clips\format_json",  
168 - out_dir=rf"R:\wdx\202604\20260420_download_football_video\finished1")