Showing
11 changed files
with
169 additions
and
1465 deletions
| @@ -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_dir=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_dir=self.cache_dir) |
| 28 | 40 | ||
| 29 | def replay_match_event(self, data): | 41 | def replay_match_event(self, data): |
| 30 | """ | 42 | """ |
| @@ -48,6 +60,10 @@ class FootballReplayMatch: | @@ -48,6 +60,10 @@ class FootballReplayMatch: | ||
| 48 | } | 60 | } |
| 49 | :return: | 61 | :return: |
| 50 | """ | 62 | """ |
| 63 | + task_id = data.get("id") | ||
| 64 | + match_id = data.get("match_id") | ||
| 65 | + replay_id = replay_info.get('id') | ||
| 66 | + matched_event_id = None | ||
| 51 | 67 | ||
| 52 | # 该时间段附近是否有进球事件 | 68 | # 该时间段附近是否有进球事件 |
| 53 | replay_info = data['replay'] | 69 | replay_info = data['replay'] |
| @@ -62,140 +78,18 @@ class FootballReplayMatch: | @@ -62,140 +78,18 @@ class FootballReplayMatch: | ||
| 62 | if replay_event_name == '进球': | 78 | if replay_event_name == '进球': |
| 63 | matched_event = self.match_by_llm(replay_info, goal_live_events) | 79 | matched_event = self.match_by_llm(replay_info, goal_live_events) |
| 64 | if matched_event is None: | 80 | if matched_event is None: |
| 65 | - logger.info(f'LLM认为是进球但是未能找到匹配的事件') | 81 | + logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 是进球但是未能找到匹配的事件") |
| 66 | else: | 82 | else: |
| 67 | - logger.info('LLM找到进球的事件') | 83 | + matched_event_id = matched_event.get('video_id') |
| 84 | + logger.info(f"{task_id}_LLM认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 68 | else: | 85 | else: |
| 69 | - logger.info(f'LLM判断不是进球,无需匹配') | ||
| 70 | - else: | ||
| 71 | - logger.info(f'通过时间找到匹配的事件') | ||
| 72 | - | ||
| 73 | - | ||
| 74 | -from .llm_image import Video2Frame | ||
| 75 | -from football_replay_match.core.football_replay_match_live import FootballReplayMatchLive | ||
| 76 | -from ..config import settings | ||
| 77 | - | ||
| 78 | - | ||
| 79 | - | ||
| 80 | -# 从配置中读取参数(配置项需在 config.yaml 或对应环境配置中定义) | ||
| 81 | -_cache_dir = settings.common.get('cache_dir', None) | ||
| 82 | -_llm_base_url = settings.llm.get('base_url', None) | ||
| 83 | -_llm_model = settings.llm.get('model_name', None) | ||
| 84 | -_llm_temperature = settings.llm.get('temperature', None) | ||
| 85 | - | ||
| 86 | -# 初始化视频处理与匹配器 | ||
| 87 | -v2f = Video2Frame(_cache_dir) | ||
| 88 | -matcher = FootballReplayMatchLive( | ||
| 89 | - base_url=_llm_base_url, | ||
| 90 | - model=_llm_model, | ||
| 91 | - temperature=_llm_temperature, | ||
| 92 | -) | ||
| 93 | - | ||
| 94 | - | ||
| 95 | -def replay_match_event(data: dict) -> dict: | ||
| 96 | - """ | ||
| 97 | - 足球回看与直播事件匹配接口。 | ||
| 98 | - | ||
| 99 | - 请求参数: | ||
| 100 | - id : str 任务唯一键 | ||
| 101 | - match_id : str 比赛id | ||
| 102 | - replay : dict 回看信息 | ||
| 103 | - |- id : str 回看id | ||
| 104 | - |- url : str 回看url(m3u8) | ||
| 105 | - |- start_utc : int 回看开始utc时间 | ||
| 106 | - |- end_utc : int 回看结束utc时间 | ||
| 107 | - events : list 赛事事件片段(直播候选) | ||
| 108 | - |- id : str 事件id | ||
| 109 | - |- type : str 事件类型(1:进球) | ||
| 110 | - |- url : str 完整视频url(m3u8) | ||
| 111 | - |- event_utc : int 事件发生的utc时间点 | ||
| 112 | - | ||
| 113 | - 响应参数: | ||
| 114 | - id : str 任务唯一键 | ||
| 115 | - match_id : str 比赛id | ||
| 116 | - replay_id : str 回看id | ||
| 117 | - event_id : str|null 匹配到的比赛片段id,null表示无匹配 | ||
| 118 | - """ | ||
| 119 | - task_id = data.get("id") | ||
| 120 | - match_id = data.get("match_id") | ||
| 121 | - replay = data.get("replay", {}) | ||
| 122 | - events = data.get("events", []) | ||
| 123 | - | ||
| 124 | - replay_id = replay.get("id") | ||
| 125 | - replay_url = replay.get("url") | ||
| 126 | - replay_start = replay.get("start_utc") | ||
| 127 | - replay_end = replay.get("end_utc") | ||
| 128 | - | ||
| 129 | - if not replay_url or replay_start is None or replay_end is None: | ||
| 130 | - raise ValueError("replay 参数不完整,需要 url、start_utc、end_utc") | ||
| 131 | - | ||
| 132 | - # 1. 为回看视频提取帧并构建 LLM content | ||
| 133 | - try: | ||
| 134 | - replay_frames = v2f.to_frames( | ||
| 135 | - url=replay_url, | ||
| 136 | - start_utc=replay_start, | ||
| 137 | - end_utc=replay_end, | ||
| 138 | - fps=2, | ||
| 139 | - ) | ||
| 140 | - replay_contents = frames2content(replay_frames) | ||
| 141 | - # 保持与 football_replay_match_live.py 中一致的提示结构 | ||
| 142 | - replay_contents.insert(0, {"type": "text", "text": "\n【回放片段信息】\n"}) | ||
| 143 | - replay_contents.append({"type": "text", "text": "\n回放解说内容:无\n"}) | ||
| 144 | - except Exception as e: | ||
| 145 | - raise RuntimeError(f"回看视频处理失败: {e}") | ||
| 146 | - | ||
| 147 | - # 2. 为每个 event(直播候选)提取帧并构建 LLM content | ||
| 148 | - # event 的 url 为完整视频,通过 event_utc 向前延长10秒、向后延长5秒截取片段 | ||
| 149 | - live_videos = [] | ||
| 150 | - for event in events: | ||
| 151 | - event_id = event.get("id") | ||
| 152 | - event_url = event.get("url") | ||
| 153 | - event_utc = event.get("event_utc") | ||
| 154 | - | ||
| 155 | - if not event_url or event_utc is None: | ||
| 156 | - continue | ||
| 157 | - | ||
| 158 | - try: | ||
| 159 | - event_start = event_utc - 10 | ||
| 160 | - event_end = event_utc + 5 | ||
| 161 | - event_frames = v2f.to_frames( | ||
| 162 | - url=event_url, | ||
| 163 | - start_utc=event_start, | ||
| 164 | - end_utc=event_end, | ||
| 165 | - fps=2, | ||
| 166 | - ) | ||
| 167 | - event_contents = frames2content(event_frames) | ||
| 168 | - event_contents.insert( | ||
| 169 | - 0, {"type": "text", "text": f"### 候选片段 video_id: {event_id} ###"} | ||
| 170 | - ) | ||
| 171 | - event_contents.append({"type": "text", "text": "\n该片段解说内容: 无\n"}) | ||
| 172 | - except Exception: | ||
| 173 | - # 单个 event 处理失败不影响整体流程 | ||
| 174 | - continue | ||
| 175 | - | ||
| 176 | - live_videos.append({ | ||
| 177 | - "video_id": event_id, | ||
| 178 | - "video_path": "", # 已提供 llm_contents,无需本地路径 | ||
| 179 | - "asr_text": "", | ||
| 180 | - "llm_contents": event_contents, | ||
| 181 | - }) | ||
| 182 | - | ||
| 183 | - # 3. 执行匹配 | ||
| 184 | - if len(live_videos) == 0: | ||
| 185 | - matched_event_id = None | ||
| 186 | - elif len(live_videos) == 1: | ||
| 187 | - # 只有一个候选片段,直接视为匹配结果 | ||
| 188 | - matched_event_id = live_videos[0]["video_id"] | 86 | + logger.info(f"{task_id}_LLM判断{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 不是进球,无需匹配") |
| 189 | else: | 87 | else: |
| 190 | - try: | ||
| 191 | - result = matcher._match_batch(replay_contents, live_videos, max_parallel=3) | ||
| 192 | - matched_event_id = result.get("video_id") if result else None | ||
| 193 | - except Exception: | ||
| 194 | - matched_event_id = None | ||
| 195 | - | 88 | + matched_event_id = matched_event.get('id') |
| 89 | + logger.info(f"{task_id}_通过时间找到匹配认为视频{replay_info['url']}_{replay_info['start_utc']}_{replay_info['end_utc']} 对应的进球的事件视频是{matched_event_id}") | ||
| 196 | return { | 90 | return { |
| 197 | "id": task_id, | 91 | "id": task_id, |
| 198 | "match_id": match_id, | 92 | "match_id": match_id, |
| 199 | "replay_id": replay_id, | 93 | "replay_id": replay_id, |
| 200 | - "event_id": matched_event_id, | 94 | + "event_id": matched_event_id |
| 201 | } | 95 | } |
| 1 | -import base64 | ||
| 2 | -import hashlib | ||
| 3 | -import subprocess | ||
| 4 | - | ||
| 5 | -import cv2 | ||
| 6 | - | ||
| 7 | -try: | ||
| 8 | - from .m3u8_to_mp4_sei import ( | ||
| 9 | - download_m3u8_to_mp4, | ||
| 10 | - extract_h264_es_from_mp4, | ||
| 11 | - parse_h264_sei, | ||
| 12 | - extract_utc_from_sei, | ||
| 13 | - ) | ||
| 14 | -except ImportError: | ||
| 15 | - from m3u8_to_mp4_sei import ( | ||
| 16 | - download_m3u8_to_mp4, | ||
| 17 | - extract_h264_es_from_mp4, | ||
| 18 | - parse_h264_sei, | ||
| 19 | - extract_utc_from_sei, | ||
| 20 | - ) | ||
| 21 | - | ||
| 22 | -# 用于区分传入的 start_utc/end_utc 是绝对 UTC 时间戳还是秒偏移 | ||
| 23 | -_IS_ABSOLUTE_UTC_THRESHOLD = 1_000_000_000 | ||
| 24 | - | ||
| 25 | - | ||
| 26 | -class Video2Frame: | ||
| 27 | - def __init__(self, cache_dir): | ||
| 28 | - self.cache_dir = Path(cache_dir) | ||
| 29 | - self.video_dir = self.cache_dir / "videos" | ||
| 30 | - self.frames_dir = self.cache_dir / "frames" | ||
| 31 | - self.video_dir.mkdir(parents=True, exist_ok=True) | ||
| 32 | - self.frames_dir.mkdir(parents=True, exist_ok=True) | ||
| 33 | - | ||
| 34 | - def to_frames(self, url, start_utc, end_utc, fps, roi=None, max_px_area=None) -> list: | ||
| 35 | - """ | ||
| 36 | - 下载 m3u8 视频流的指定片段并提取帧,保存到 cache_dir 下。 | ||
| 37 | - | ||
| 38 | - - video_dir 中保存的是 start_utc ~ end_utc 截取后的视频片段(而非完整视频)。 | ||
| 39 | - - 如果视频中包含 SEI UTC 信息且传入的 start_utc/end_utc 为绝对时间戳, | ||
| 40 | - 则会基于 SEI UTC 进行帧级定位;否则将 start_utc/end_utc 视为秒偏移量。 | ||
| 41 | - | ||
| 42 | - Args: | ||
| 43 | - url: m3u8 视频流地址。 | ||
| 44 | - start_utc: 截取开始时间(秒偏移或绝对 UTC 时间戳)。 | ||
| 45 | - end_utc: 截取结束时间(秒偏移或绝对 UTC 时间戳)。 | ||
| 46 | - fps: 目标抽帧帧率。 | ||
| 47 | - roi: 感兴趣区域 (x, y, w, h),可选。 | ||
| 48 | - max_px_area: 最大像素面积,超过则等比例缩小,可选。 | ||
| 49 | - | ||
| 50 | - Returns: | ||
| 51 | - 提取的帧图片路径列表。 | ||
| 52 | - """ | ||
| 53 | - if end_utc <= start_utc: | ||
| 54 | - raise ValueError("end_utc 必须大于 start_utc") | ||
| 55 | - | ||
| 56 | - unique_str = f"{url}_{start_utc}_{end_utc}" | ||
| 57 | - unique_id = hashlib.md5(unique_str.encode("utf-8")).hexdigest() | ||
| 58 | - | ||
| 59 | - clip_path = self.video_dir / f"{unique_id}.mp4" | ||
| 60 | - frame_output_dir = self.frames_dir / unique_id | ||
| 61 | - frame_output_dir.mkdir(parents=True, exist_ok=True) | ||
| 62 | - | ||
| 63 | - # 判断是否为绝对 UTC 时间戳 | ||
| 64 | - is_absolute_utc = ( | ||
| 65 | - start_utc > _IS_ABSOLUTE_UTC_THRESHOLD | ||
| 66 | - and end_utc > _IS_ABSOLUTE_UTC_THRESHOLD | ||
| 67 | - ) | ||
| 68 | - | ||
| 69 | - # 若片段未缓存,先截取目标片段再保存 | ||
| 70 | - if not clip_path.exists(): | ||
| 71 | - if is_absolute_utc: | ||
| 72 | - # 绝对 UTC 模式:先下载完整视频到临时文件,解析 SEI 后截取片段 | ||
| 73 | - temp_path = self.video_dir / f"{unique_id}_full.mp4" | ||
| 74 | - download_m3u8_to_mp4(url, str(temp_path)) | ||
| 75 | - | ||
| 76 | - # 提取 SEI UTC 信息 | ||
| 77 | - utc_records = [] | ||
| 78 | - try: | ||
| 79 | - es_data = extract_h264_es_from_mp4(str(temp_path)) | ||
| 80 | - sei_list = parse_h264_sei(es_data) | ||
| 81 | - utc_records = extract_utc_from_sei(sei_list) | ||
| 82 | - except Exception: | ||
| 83 | - pass | ||
| 84 | - | ||
| 85 | - if utc_records: | ||
| 86 | - utc_list = [r["utc"] for r in utc_records] | ||
| 87 | - start_frame_idx = self._find_frame_idx( | ||
| 88 | - utc_list, start_utc, find_last_not_greater=False | ||
| 89 | - ) | ||
| 90 | - end_frame_idx = self._find_frame_idx( | ||
| 91 | - utc_list, end_utc, find_last_not_greater=True | ||
| 92 | - ) | ||
| 93 | - | ||
| 94 | - cap_temp = cv2.VideoCapture(str(temp_path)) | ||
| 95 | - video_fps = cap_temp.get(cv2.CAP_PROP_FPS) | ||
| 96 | - cap_temp.release() | ||
| 97 | - if video_fps <= 0: | ||
| 98 | - video_fps = 25.0 | ||
| 99 | - | ||
| 100 | - start_sec = start_frame_idx / video_fps | ||
| 101 | - duration = (end_frame_idx - start_frame_idx) / video_fps | ||
| 102 | - self._ffmpeg_extract_clip( | ||
| 103 | - str(temp_path), str(clip_path), start_sec, duration | ||
| 104 | - ) | ||
| 105 | - else: | ||
| 106 | - # 无 SEI 无法定位,直接将完整视频重命名为片段 | ||
| 107 | - temp_path.rename(clip_path) | ||
| 108 | - | ||
| 109 | - # 清理临时完整视频 | ||
| 110 | - if temp_path.exists(): | ||
| 111 | - temp_path.unlink(missing_ok=True) | ||
| 112 | - else: | ||
| 113 | - # 秒偏移模式:直接用 ffmpeg 从 URL 截取片段 | ||
| 114 | - duration = end_utc - start_utc | ||
| 115 | - self._ffmpeg_download_clip( | ||
| 116 | - url, str(clip_path), start_utc, duration | ||
| 117 | - ) | ||
| 118 | - | ||
| 119 | - # 从截取后的片段中提取帧 | ||
| 120 | - cap = cv2.VideoCapture(str(clip_path)) | ||
| 121 | - if not cap.isOpened(): | ||
| 122 | - raise ValueError(f"无法打开视频片段: {clip_path}") | ||
| 123 | - | ||
| 124 | - # total_frames = int(cap.get(cv2.CAP_PROP_FRAME_COUNT)) | ||
| 125 | - video_fps = cap.get(cv2.CAP_PROP_FPS) | ||
| 126 | - if video_fps <= 0: | ||
| 127 | - video_fps = 25.0 | ||
| 128 | - | ||
| 129 | - # 计算抽帧间隔 | ||
| 130 | - frame_interval = int(video_fps / fps) if fps > 0 else int(video_fps) | ||
| 131 | - if frame_interval < 1: | ||
| 132 | - frame_interval = 1 | ||
| 133 | - | ||
| 134 | - frames = [] | ||
| 135 | - saved_count = 0 | ||
| 136 | - frame_count = 0 | ||
| 137 | - while True: | ||
| 138 | - ret, frame = cap.read() | ||
| 139 | - if not ret: | ||
| 140 | - break | ||
| 141 | - | ||
| 142 | - if frame_count % frame_interval == 0: | ||
| 143 | - if roi is not None: | ||
| 144 | - x, y, w, h = roi | ||
| 145 | - frame = frame[y:y + h, x:x + w] | ||
| 146 | - frame = self.reisize_frame(frame, max_px_area) | ||
| 147 | - frames.append(frame) | ||
| 148 | - frame_path = frame_output_dir / f"frame_{saved_count:06d}.jpg" | ||
| 149 | - cv2.imwrite(str(frame_path), frame) | ||
| 150 | - saved_count += 1 | ||
| 151 | - frame_count += 1 | ||
| 152 | - cap.release() | ||
| 153 | - return frames | ||
| 154 | - | ||
| 155 | - def reisize_frame(self, frame, max_px_area): | ||
| 156 | - if max_px_area is None: | ||
| 157 | - return frame | ||
| 158 | - h, w = frame.shape[:2] | ||
| 159 | - area = h * w | ||
| 160 | - if area > max_px_area: | ||
| 161 | - scale = (max_px_area / area) ** 0.5 | ||
| 162 | - new_w = int(w * scale) | ||
| 163 | - new_h = int(h * scale) | ||
| 164 | - frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA) | ||
| 165 | - return frame | ||
| 166 | - | ||
| 167 | - @staticmethod | ||
| 168 | - def _find_frame_idx(utc_list, target_utc, find_last_not_greater=True): | ||
| 169 | - """ | ||
| 170 | - 在 utc_list 中查找最接近 target_utc 的帧索引。 | ||
| 171 | - | ||
| 172 | - Args: | ||
| 173 | - utc_list: 按帧顺序排列的 UTC 列表。 | ||
| 174 | - target_utc: 目标 UTC。 | ||
| 175 | - find_last_not_greater: True 返回最后一个 <= target_utc 的索引; | ||
| 176 | - False 返回第一个 >= target_utc 的索引。 | ||
| 177 | - """ | ||
| 178 | - if not utc_list: | ||
| 179 | - return 0 | ||
| 180 | - | ||
| 181 | - if find_last_not_greater: | ||
| 182 | - idx = 0 | ||
| 183 | - for i, utc in enumerate(utc_list): | ||
| 184 | - if utc > target_utc: | ||
| 185 | - break | ||
| 186 | - idx = i | ||
| 187 | - return idx | ||
| 188 | - else: | ||
| 189 | - for i, utc in enumerate(utc_list): | ||
| 190 | - if utc >= target_utc: | ||
| 191 | - return i | ||
| 192 | - return len(utc_list) - 1 | ||
| 193 | - | ||
| 194 | - @staticmethod | ||
| 195 | - def _ffmpeg_extract_clip(input_path, output_path, start_sec, duration): | ||
| 196 | - """使用 ffmpeg 从本地视频截取指定片段。""" | ||
| 197 | - cmd = [ | ||
| 198 | - "ffmpeg", "-y", | ||
| 199 | - "-i", input_path, | ||
| 200 | - "-ss", str(start_sec), | ||
| 201 | - "-t", str(duration), | ||
| 202 | - "-c", "copy", | ||
| 203 | - "-bsf:a", "aac_adtstoasc", | ||
| 204 | - "-movflags", "+faststart", | ||
| 205 | - output_path, | ||
| 206 | - ] | ||
| 207 | - try: | ||
| 208 | - subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
| 209 | - except subprocess.CalledProcessError as e: | ||
| 210 | - raise RuntimeError( | ||
| 211 | - f"ffmpeg 截取片段失败: {e.stderr.decode('utf-8', errors='ignore')}" | ||
| 212 | - ) | ||
| 213 | - | ||
| 214 | - @staticmethod | ||
| 215 | - def _ffmpeg_download_clip(url, output_path, start_sec, duration): | ||
| 216 | - """使用 ffmpeg 从 URL 下载并截取指定片段。""" | ||
| 217 | - cmd = [ | ||
| 218 | - "ffmpeg", "-y", | ||
| 219 | - "-ss", str(start_sec), | ||
| 220 | - "-fflags", "+discardcorrupt", | ||
| 221 | - "-i", url, | ||
| 222 | - "-t", str(duration), | ||
| 223 | - "-c", "copy", | ||
| 224 | - "-bsf:a", "aac_adtstoasc", | ||
| 225 | - "-movflags", "+faststart", | ||
| 226 | - output_path, | ||
| 227 | - ] | ||
| 228 | - try: | ||
| 229 | - subprocess.run(cmd, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) | ||
| 230 | - except subprocess.CalledProcessError as e: | ||
| 231 | - raise RuntimeError( | ||
| 232 | - f"ffmpeg 下载片段失败: {e.stderr.decode('utf-8', errors='ignore')}" | ||
| 233 | - ) | ||
| 234 | - | ||
| 235 | - | ||
| 236 | -def frames2content(frames): | ||
| 237 | - """ | ||
| 238 | - 将帧列表(图片路径列表)转为 LLM content 列表。 | ||
| 239 | - 格式与 llm_video_content.py 中的 contents 返回的 content 一致。 | ||
| 240 | - """ | ||
| 241 | - contents = [] | ||
| 242 | - video_prompt = ( | ||
| 243 | - f"以下是从视频中按时间顺序提取的 {len(frames)} 帧画面," | ||
| 244 | - f"请将它们视为一个连续的视频进行分析。" | ||
| 245 | - ) | ||
| 246 | - contents.append({"type": "text", "text": video_prompt}) | ||
| 247 | - | ||
| 248 | - for frame in frames: | ||
| 249 | - _, buffer = cv2.imencode('.jpg', frame) | ||
| 250 | - b64_str = base64.b64encode(buffer).decode('utf-8') | ||
| 251 | - | ||
| 252 | - # with open(frame_path, "rb") as f: | ||
| 253 | - # img_bytes = f.read() | ||
| 254 | - # b64_str = base64.b64encode(img_bytes).decode("utf-8") | ||
| 255 | - | ||
| 256 | - contents.append({ | ||
| 257 | - "type": "image_url", | ||
| 258 | - "image_url": { | ||
| 259 | - "url": f"data:image/jpeg;base64,{b64_str}" | ||
| 260 | - } | ||
| 261 | - }) | ||
| 262 | - | ||
| 263 | - return contents | ||
| 264 | - | ||
| 265 | - | ||
| 266 | -if __name__ == "__main__": | ||
| 267 | - # pass | ||
| 268 | - | ||
| 269 | - import argparse | ||
| 270 | - import sys | ||
| 271 | - import tempfile | ||
| 272 | - | ||
| 273 | - parser = argparse.ArgumentParser(description="Video2Frame 测试脚本") | ||
| 274 | - parser.add_argument( | ||
| 275 | - "--url", | ||
| 276 | - default=r"D:\pythonProject\learn\3b3e99cf4ca84c3782503d8817242de2.mp4", | ||
| 277 | - help="测试用 m3u8 地址(默认使用公开测试流)", | ||
| 278 | - ) | ||
| 279 | - parser.add_argument( | ||
| 280 | - "--cache-dir", | ||
| 281 | - default=r"C:\Users\lzw\AppData\Local\Temp\video2frame_test_mn26tcy_my", | ||
| 282 | - help="缓存目录(默认自动创建临时目录)", | ||
| 283 | - ) | ||
| 284 | - parser.add_argument( | ||
| 285 | - "--start", | ||
| 286 | - type=float, | ||
| 287 | - default=0, | ||
| 288 | - help="截取开始时间,单位:秒(默认 0)", | ||
| 289 | - ) | ||
| 290 | - parser.add_argument( | ||
| 291 | - "--end", | ||
| 292 | - type=float, | ||
| 293 | - default=10, | ||
| 294 | - help="截取结束时间,单位:秒(默认 10)", | ||
| 295 | - ) | ||
| 296 | - parser.add_argument( | ||
| 297 | - "--fps", | ||
| 298 | - type=float, | ||
| 299 | - default=2.0, | ||
| 300 | - help="抽帧帧率(默认 1.0)", | ||
| 301 | - ) | ||
| 302 | - args = parser.parse_args() | ||
| 303 | - | ||
| 304 | - cache_dir = args.cache_dir or tempfile.mkdtemp(prefix="video2frame_test_") | ||
| 305 | - print(f"[INFO] 缓存目录: {cache_dir}") | ||
| 306 | - | ||
| 307 | - v2f = Video2Frame(cache_dir) | ||
| 308 | - | ||
| 309 | - # TEST 1: 基本抽帧 | ||
| 310 | - print( | ||
| 311 | - f"\n[TEST 1] to_frames: url={args.url}, " | ||
| 312 | - f"start={args.start}s, end={args.end}s, fps={args.fps}" | ||
| 313 | - ) | ||
| 314 | - try: | ||
| 315 | - frames = v2f.to_frames( | ||
| 316 | - url=args.url, | ||
| 317 | - start_utc=args.start, | ||
| 318 | - end_utc=args.end, | ||
| 319 | - fps=args.fps, | ||
| 320 | - roi=None, | ||
| 321 | - max_px_area=200_000, | ||
| 322 | - ) | ||
| 323 | - print(f" 成功提取 {len(frames)} 帧") | ||
| 324 | - if frames: | ||
| 325 | - print(f" 首帧: {frames[0].shape}") | ||
| 326 | - print(f" 末帧: {frames[-1].shape}") | ||
| 327 | - except Exception as e: | ||
| 328 | - print(f" 失败: {e}") | ||
| 329 | - import traceback | ||
| 330 | - | ||
| 331 | - traceback.print_exc() | ||
| 332 | - sys.exit(1) | ||
| 333 | - | ||
| 334 | - # TEST 2: frames2content | ||
| 335 | - print("\n[TEST 2] frames2content") | ||
| 336 | - content = frames2content(frames) | ||
| 337 | - print(f" content 列表长度: {len(content)}") | ||
| 338 | - for item in content[:3]: | ||
| 339 | - preview = ( | ||
| 340 | - item["text"][:60] + "..." | ||
| 341 | - if item["type"] == "text" | ||
| 342 | - else item["image_url"]["url"][:60] + "..." | ||
| 343 | - ) | ||
| 344 | - print(f" - type={item['type']}: {preview}") | ||
| 345 | - if len(content) > 3: | ||
| 346 | - print(f" ... 还有 {len(content) - 3} 个元素") | ||
| 347 | - | ||
| 348 | - print("\n[TEST 3] FootballReplayVideoEvent 进球识别(可选)") | ||
| 349 | - | ||
| 350 | - try: | ||
| 351 | - from football_replay_match.core.football_replay_video_event_by_llm import FootballReplayVideoEvent | ||
| 352 | - except ImportError: | ||
| 353 | - import sys | ||
| 354 | - from pathlib import Path | ||
| 355 | - sys.path.insert(0, str(Path(__file__).parent.parent)) | ||
| 356 | - from util.football_replay_video_event_by_llm import FootballReplayVideoEvent | ||
| 357 | - | ||
| 358 | - fbrv = FootballReplayVideoEvent( | ||
| 359 | - base_url="http://192.168.1.59:11434", | ||
| 360 | - model="qwen3.6:35b-a3b-q8_0", | ||
| 361 | - temperature=0.7, | ||
| 362 | - ) | ||
| 363 | - event_json = fbrv.video_event_for_contents(content, None) | ||
| 364 | - print(f" 识别结果: {event_json}") | ||
| 365 | - | ||
| 366 | - | ||
| 367 | - # # TEST 3: ROI + max_px_area | ||
| 368 | - # print("\n[TEST 3] to_frames with roi + max_px_area") | ||
| 369 | - # try: | ||
| 370 | - # end_crop = min(args.start + 5, args.end) | ||
| 371 | - # frames_cropped = v2f.to_frames( | ||
| 372 | - # url=args.url, | ||
| 373 | - # start_utc=args.start, | ||
| 374 | - # end_utc=end_crop, | ||
| 375 | - # fps=1.0, | ||
| 376 | - # roi=None, | ||
| 377 | - # max_px_area=200_000, | ||
| 378 | - # ) | ||
| 379 | - # print(f" 成功提取 {len(frames_cropped)} 帧(带 ROI 裁剪和面积缩放)") | ||
| 380 | - # except Exception as e: | ||
| 381 | - # print(f" 跳过/失败: {e}") | ||
| 382 | - # | ||
| 383 | - # # TEST 4: SEI UTC 模式说明(仅演示,需真实含 SEI 的流才能运行) | ||
| 384 | - # print("\n[TEST 4] SEI UTC 绝对时间戳模式(示例代码,需替换为含 SEI 的流)") | ||
| 385 | - # print( | ||
| 386 | - # " # 示例:假设视频第一帧 UTC 为 1715600000,提取 10 秒片段\n" | ||
| 387 | - # " # frames = v2f.to_frames(url, start_utc=1715600000, end_utc=1715600010, fps=1)" | ||
| 388 | - # ) | ||
| 389 | - # | ||
| 390 | - # 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: |
| 10 | +<<<<<<<< HEAD:src/football_replay_match/core/football_replay_match_live.py | ||
| 9 | from football_replay_match.util.llm_video_content import contents as video_contents | 11 | from football_replay_match.util.llm_video_content import contents as video_contents |
| 12 | +======== | ||
| 13 | + from .llm_image import Video2Frame | ||
| 14 | +>>>>>>>> b6bd61f9eadc8b70a6ff2e7925f45fc7a9ed39f0:src/football_replay_match/core/utils/football_replay_match_live.py | ||
| 10 | except: | 15 | except: |
| 11 | - from llm_video_content import contents as video_contents | 16 | + from llm_image import Video2Frame |
| 12 | 17 | ||
| 13 | req_prompt = """ | 18 | req_prompt = """ |
| 14 | # Role | 19 | # Role |
| @@ -70,7 +75,7 @@ req_prompt = """ | @@ -70,7 +75,7 @@ req_prompt = """ | ||
| 70 | 75 | ||
| 71 | 76 | ||
| 72 | class FootballReplayMatchLive: | 77 | class FootballReplayMatchLive: |
| 73 | - def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key'): | 78 | + 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 | 79 | self.base_url = base_url |
| 75 | self.model = model | 80 | self.model = model |
| 76 | self.temperature = temperature | 81 | self.temperature = temperature |
| @@ -82,12 +87,15 @@ class FootballReplayMatchLive: | @@ -82,12 +87,15 @@ class FootballReplayMatchLive: | ||
| 82 | self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, | 87 | self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, |
| 83 | extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | 88 | extra_body={"chat_template_kwargs": {"enable_thinking": False}}) |
| 84 | 89 | ||
| 85 | - def _match_once(self, replay_video_contents: list, live_videos: list[dict], cache_path=None, record: list = None): | 90 | + self.cache_dir = cache_dir |
| 91 | + self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) | ||
| 92 | + | ||
| 93 | + def _match_once(self, replay_video_contents: list, live_videos: list[dict], record: list = None): | ||
| 86 | 94 | ||
| 87 | if len(live_videos) == 0: | 95 | if len(live_videos) == 0: |
| 88 | return None | 96 | return None |
| 89 | elif len(live_videos) == 1: | 97 | elif len(live_videos) == 1: |
| 90 | - live_video_path = live_videos[0].get("video_path", None) | 98 | + 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)) | 99 | live_video_id = live_videos[0].get("video_id", os.path.basename(live_video_path)) |
| 92 | asr_text = live_videos[0].get("asr_text", '') | 100 | asr_text = live_videos[0].get("asr_text", '') |
| 93 | live = { | 101 | live = { |
| @@ -107,16 +115,24 @@ class FootballReplayMatchLive: | @@ -107,16 +115,24 @@ class FootballReplayMatchLive: | ||
| 107 | live_map = {} | 115 | live_map = {} |
| 108 | live_records = {} | 116 | live_records = {} |
| 109 | for live_video in live_videos: | 117 | for live_video in live_videos: |
| 110 | - live_video_path = live_video.get("video_path", None) | 118 | + live_video_path = live_video.get("url", None) |
| 111 | live_video_id = live_video.get("video_id", os.path.basename(live_video_path)) | 119 | live_video_id = live_video.get("video_id", os.path.basename(live_video_path)) |
| 120 | + event_utc = live_video.get("event_utc", None) | ||
| 112 | asr_text = live_video.get("asr_text", '') | 121 | asr_text = live_video.get("asr_text", '') |
| 113 | live_video_contents = live_video.get("llm_contents", None) | 122 | live_video_contents = live_video.get("llm_contents", None) |
| 114 | if live_video_contents is None: | 123 | if live_video_contents is None: |
| 115 | - live_video_contents = video_contents(live_video_path, | 124 | + event_start = event_utc - timedelta(seconds=10) if event_utc is not None else None |
| 125 | + event_end = event_utc + timedelta(seconds=5) if event_utc is not None else None | ||
| 126 | + live_video_contents = self.video2frame.to_llm_contents( live_video_path, | ||
| 127 | + cache=self.cache_dir, | ||
| 128 | + fps=2, | ||
| 129 | + start=event_start, | ||
| 130 | + end=event_end, | ||
| 131 | + roi=None, | ||
| 132 | + max_px_area=400_000, | ||
| 116 | prompt_start=f"### 候选片段 video_id: {live_video_id} ###", | 133 | 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) | 134 | + prompt_end=f"\n该片段解说内容: {asr_text}\n" |
| 135 | + ) | ||
| 120 | live_map[live_video_id] = { | 136 | live_map[live_video_id] = { |
| 121 | "video_id": live_video_id, | 137 | "video_id": live_video_id, |
| 122 | "video_path": live_video_path, | 138 | "video_path": live_video_path, |
| @@ -180,7 +196,7 @@ class FootballReplayMatchLive: | @@ -180,7 +196,7 @@ class FootballReplayMatchLive: | ||
| 180 | if len(live_videos_groups) > 1: | 196 | if len(live_videos_groups) > 1: |
| 181 | match_result = [] | 197 | match_result = [] |
| 182 | for live_videos_group in live_videos_groups: | 198 | for live_videos_group in live_videos_groups: |
| 183 | - g_live = self._match_once(replay_video_contents, live_videos_group, cache_path, record) | 199 | + g_live = self._match_once(replay_video_contents, live_videos_group, record) |
| 184 | if g_live is None: | 200 | if g_live is None: |
| 185 | continue | 201 | continue |
| 186 | match_result.append(g_live) | 202 | match_result.append(g_live) |
| @@ -189,21 +205,33 @@ class FootballReplayMatchLive: | @@ -189,21 +205,33 @@ class FootballReplayMatchLive: | ||
| 189 | else: | 205 | else: |
| 190 | return self._match_batch(replay_video_contents, match_result, max_parallel, cache_path, record) | 206 | return self._match_batch(replay_video_contents, match_result, max_parallel, cache_path, record) |
| 191 | elif len(live_videos_groups) == 1: | 207 | elif len(live_videos_groups) == 1: |
| 192 | - return self._match_once(replay_video_contents, live_videos_groups[0], cache_path, record) | 208 | + return self._match_once(replay_video_contents, live_videos_groups[0], record) |
| 193 | else: | 209 | else: |
| 194 | return None | 210 | return None |
| 195 | 211 | ||
| 196 | - def match_batch(self, replay_video: dict, live_videos: list[dict], max_parallel: int = 3, cache_path=None): | 212 | + def match_batch(self, replay_video: dict, live_videos: list[dict], max_parallel: int = 3, cache_dir=None): |
| 213 | + | ||
| 214 | + cache_path = os.path.join(cache_dir, 'match_live.json') | ||
| 197 | if cache_path is not None and os.path.exists(cache_path): | 215 | if cache_path is not None and os.path.exists(cache_path): |
| 198 | try: | 216 | try: |
| 199 | with open(cache_path, 'r', encoding='utf-8') as f: | 217 | with open(cache_path, 'r', encoding='utf-8') as f: |
| 200 | return json.loads(f.read()).get("result", None) | 218 | return json.loads(f.read()).get("result", None) |
| 201 | except: | 219 | except: |
| 202 | os.remove(cache_path) | 220 | 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) | 221 | + |
| 222 | + replay_video_path = replay_video.get("url", None) | ||
| 223 | + event_start = replay_video.get("start_utc", None) | ||
| 224 | + event_end = replay_video.get("end_utc", None) | ||
| 225 | + replay_video_contents = self.video2frame.to_llm_contents(replay_video_path, | ||
| 226 | + cache=self.cache_dir, | ||
| 227 | + fps=2, | ||
| 228 | + start=event_start, | ||
| 229 | + end=event_end, | ||
| 230 | + roi=None, | ||
| 231 | + max_px_area=400_000, | ||
| 232 | + prompt_start="\n【回放片段信息】\n", | ||
| 233 | + prompt_end=f"\n回放解说内容:无\n" | ||
| 234 | + ) | ||
| 207 | live_record = [] | 235 | live_record = [] |
| 208 | result = self._match_batch(replay_video_contents, live_videos, max_parallel, cache_path, live_record) | 236 | result = self._match_batch(replay_video_contents, live_videos, max_parallel, cache_path, live_record) |
| 209 | if result is not None: | 237 | if result is not None: |
| @@ -6,9 +6,14 @@ from langchain_core.messages import SystemMessage, HumanMessage | @@ -6,9 +6,14 @@ 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 | +<<<<<<<< HEAD:src/football_replay_match/core/football_replay_video_event_by_llm.py | ||
| 9 | from football_replay_match.util.llm_video_content import contents as video_contents | 10 | from football_replay_match.util.llm_video_content import contents as video_contents |
| 11 | +======== | ||
| 12 | + from .llm_image import Video2Frame | ||
| 13 | +>>>>>>>> b6bd61f9eadc8b70a6ff2e7925f45fc7a9ed39f0:src/football_replay_match/core/utils/football_replay_video_event_by_llm.py | ||
| 10 | except: | 14 | except: |
| 11 | - from llm_video_content import contents as video_contents | 15 | + from llm_image import Video2Frame |
| 16 | + | ||
| 12 | 17 | ||
| 13 | req_prompt = """ | 18 | req_prompt = """ |
| 14 | ### 角色设定 | 19 | ### 角色设定 |
| @@ -65,7 +70,7 @@ req_prompt = """ | @@ -65,7 +70,7 @@ req_prompt = """ | ||
| 65 | 70 | ||
| 66 | 71 | ||
| 67 | class FootballReplayVideoEvent: | 72 | class FootballReplayVideoEvent: |
| 68 | - def __init__(self, base_url: str, model: str, temperature: float = 0.0, api_key: str = 'no_key'): | 73 | + 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 | 74 | self.base_url = base_url |
| 70 | self.model = model | 75 | self.model = model |
| 71 | self.temperature = temperature | 76 | self.temperature = temperature |
| @@ -77,31 +82,30 @@ class FootballReplayVideoEvent: | @@ -77,31 +82,30 @@ class FootballReplayVideoEvent: | ||
| 77 | self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, | 82 | self.model = ChatOpenAI(base_url=base_url, model=model, temperature=temperature, api_key=api_key, |
| 78 | extra_body={"chat_template_kwargs": {"enable_thinking": False}}) | 83 | extra_body={"chat_template_kwargs": {"enable_thinking": False}}) |
| 79 | 84 | ||
| 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()) | 85 | + self.cache_dir = cache_dir |
| 86 | + self.video2frame = Video2Frame(cache_dir=cache_dir, save_frames_enable=save_frames_enable) | ||
| 84 | 87 | ||
| 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 | 88 | + def video_event(self, replay_pack: dict, asr_text: str = '无', cache_dir=None): |
| 89 | + replay_video_path = replay_pack.get("url", None) | ||
| 100 | 90 | ||
| 101 | - def video_event_for_contents(self, contents: list, asr_text: str = '无', cache_path=None): | 91 | + cache_path = os.path.join(cache_dir, 'video_event.json') |
| 102 | if cache_path is not None and os.path.exists(cache_path): | 92 | if cache_path is not None and os.path.exists(cache_path): |
| 103 | with open(cache_path, 'r', encoding='utf-8') as f: | 93 | with open(cache_path, 'r', encoding='utf-8') as f: |
| 104 | return json.loads(f.read()) | 94 | return json.loads(f.read()) |
| 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 | -from football_replay_match.core.football_replay_match_live import FootballReplayMatchLive | ||
| 2 | -from qwen_asr_util import QwenAsr | ||
| 3 | -from football_replay_match.core.football_replay_video_event_by_llm import FootballReplayVideoEvent | 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 | 4 | import os |
| 5 | +import json | ||
| 5 | 6 | ||
| 6 | 7 | ||
| 7 | def batch_match(): | 8 | def batch_match(): |
| 8 | - replay_match_live = FootballReplayMatchLive(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf") | ||
| 9 | - qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B") | ||
| 10 | - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7) | 9 | + cache_dir = "/root/lzw/tmp_0518_replay_cache03" |
| 10 | + replay_match_live = FootballReplayMatchLive(base_url="http://192.168.1.59:11434/v1", | ||
| 11 | + model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 12 | + temperature=0.7, | ||
| 13 | + cache_dir=cache_dir, | ||
| 14 | + save_frames_enable=True | ||
| 15 | + ) | ||
| 16 | + # qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B") | ||
| 17 | + fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", | ||
| 18 | + model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", | ||
| 19 | + temperature=0.7, | ||
| 20 | + cache_dir=cache_dir, | ||
| 21 | + save_frames_enable=True | ||
| 22 | + ) | ||
| 11 | 23 | ||
| 12 | - videos_dir = rf"R:\wdx\202604\20260420_download_football_video\finished" | 24 | + videos_dir = rf"/root/lzw/finished" |
| 13 | for video_name in sorted(os.listdir(videos_dir)): | 25 | for video_name in sorted(os.listdir(videos_dir)): |
| 14 | 26 | ||
| 15 | live_dir = os.path.join(videos_dir, video_name, 'live') | 27 | live_dir = os.path.join(videos_dir, video_name, 'live') |
| 28 | + print(f"live_dir: {live_dir}") | ||
| 29 | + | ||
| 16 | live_video_dir = os.path.join(live_dir, 'videos') | 30 | live_video_dir = os.path.join(live_dir, 'videos') |
| 17 | live_asr_dir = os.path.join(live_dir, 'asr') | 31 | live_asr_dir = os.path.join(live_dir, 'asr') |
| 18 | 32 | ||
| @@ -24,7 +38,7 @@ def batch_match(): | @@ -24,7 +38,7 @@ def batch_match(): | ||
| 24 | live_asr_text = '' | 38 | live_asr_text = '' |
| 25 | live_packs.append({ | 39 | live_packs.append({ |
| 26 | "video_id": os.path.basename(live_video_path), | 40 | "video_id": os.path.basename(live_video_path), |
| 27 | - "video_path": live_video_path, | 41 | + "url": live_video_path, |
| 28 | "asr_text": live_asr_text | 42 | "asr_text": live_asr_text |
| 29 | }) | 43 | }) |
| 30 | 44 | ||
| @@ -36,30 +50,47 @@ def batch_match(): | @@ -36,30 +50,47 @@ def batch_match(): | ||
| 36 | 50 | ||
| 37 | for replay_video_name in sorted(os.listdir(replay_videos_dir)): | 51 | for replay_video_name in sorted(os.listdir(replay_videos_dir)): |
| 38 | replay_video_path = os.path.join(replay_videos_dir, replay_video_name) | 52 | replay_video_path = os.path.join(replay_videos_dir, replay_video_name) |
| 39 | - replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json") | 53 | + # replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json") |
| 54 | + | ||
| 55 | + # replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.txt") | ||
| 56 | + # replay_match_path = os.path.join(replay_matches_dir, f"{replay_video_name}.json") | ||
| 40 | 57 | ||
| 41 | - replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.txt") | ||
| 42 | - replay_match_path = os.path.join(replay_matches_dir, f"{replay_video_name}.json") | 58 | + # print(100*"*") |
| 59 | + # print(replay_match_path) | ||
| 60 | + replay_match_path = None | ||
| 43 | 61 | ||
| 44 | # replay_asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path) | 62 | # replay_asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path) |
| 45 | replay_asr_text = '' | 63 | replay_asr_text = '' |
| 64 | + replay_pack = {"url": "http://video.mam.miguvideo.com/mnt6/fastclip3/wsc/2026/04/29/df760b8d15394cbc9ed0d0a4a9c4b786_1080PS/29133605/vodtmp/3b3e99cf4ca84c3782503d8817242de2.m3u8", | ||
| 65 | + "start_utc": 1777444502543, | ||
| 66 | + "end_utc": 1777444531063, | ||
| 67 | + "asr_text": '无' | ||
| 68 | + } | ||
| 46 | 69 | ||
| 47 | - replay_goals = fbrv.video_event(replay_video_path, cache_path=replay_goals_path) | 70 | + replay_goals = fbrv.video_event(replay_pack, cache_dir=cache_dir) |
| 48 | if replay_goals['event_name'] == '无进球': | 71 | if replay_goals['event_name'] == '无进球': |
| 49 | continue | 72 | continue |
| 50 | - replay_pack = {"video_path": replay_video_path, "asr_text": replay_asr_text} | 73 | + # replay_pack = {"video_path": replay_video_path, "asr_text": replay_asr_text} |
| 51 | 74 | ||
| 52 | try: | 75 | try: |
| 53 | - result = replay_match_live.match_batch(replay_pack, live_packs, max_parallel=2, | ||
| 54 | - cache_path=replay_match_path) | 76 | + import time |
| 77 | + start_time = time.time() | ||
| 78 | + print("Start matching...") | ||
| 79 | + print(f"len(live_packs): {len(live_packs)}") | ||
| 80 | + result = replay_match_live.match_batch(replay_pack, live_packs, max_parallel=2, cache_dir=cache_dir) | ||
| 81 | + end_time = time.time() | ||
| 82 | + print(f"Time taken for matching: {end_time - start_time:.2f} seconds") | ||
| 55 | print(replay_video_path) | 83 | print(replay_video_path) |
| 56 | print(result) | 84 | print(result) |
| 85 | + break | ||
| 57 | 86 | ||
| 58 | except Exception as e: | 87 | except Exception as e: |
| 59 | print(f"Error processing {replay_video_path}: {e}") | 88 | print(f"Error processing {replay_video_path}: {e}") |
| 60 | 89 | ||
| 61 | print('-' * 20) | 90 | print('-' * 20) |
| 62 | 91 | ||
| 92 | + break | ||
| 93 | + | ||
| 63 | 94 | ||
| 64 | if __name__ == '__main__': | 95 | if __name__ == '__main__': |
| 65 | batch_match() | 96 | batch_match() |
| 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 | -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 os | ||
| 2 | - | ||
| 3 | -from football_replay_match.core.football_replay_video_event_by_llm import FootballReplayVideoEvent | ||
| 4 | -from qwen_asr_util import QwenAsr | ||
| 5 | -def batch_with_asr(): | ||
| 6 | - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7) | ||
| 7 | - qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B",temperature=0.7, | ||
| 8 | - api_key='no_key') | ||
| 9 | - | ||
| 10 | - videos_dir = rf"R:\wdx\202604\20260420_download_football_video\finished" | ||
| 11 | - for video_name in sorted(os.listdir(videos_dir)): | ||
| 12 | - replays_dir = os.path.join(videos_dir, video_name, 'replays') | ||
| 13 | - replay_videos_dir = os.path.join(replays_dir, 'videos') | ||
| 14 | - replay_goals_dir = os.path.join(replays_dir, 'goals') | ||
| 15 | - replay_asr_dir = os.path.join(replays_dir, 'asr') | ||
| 16 | - | ||
| 17 | - for replay_video_name in sorted(os.listdir(replay_videos_dir)): | ||
| 18 | - replay_video_path = os.path.join(replay_videos_dir, replay_video_name) | ||
| 19 | - replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json") | ||
| 20 | - replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.json") | ||
| 21 | - if os.path.exists(replay_goals_path): | ||
| 22 | - print("skip", replay_video_path) | ||
| 23 | - continue | ||
| 24 | - asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path) | ||
| 25 | - event_json = fbrv.video_event(replay_video_name, asr_text) | ||
| 26 | - print(event_json) | ||
| 27 | - | ||
| 28 | -def batch_without_asr(): | ||
| 29 | - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7) | ||
| 30 | - # qwen_asr = QwenAsr(base_url="http://192.168.1.59:8101/v1", model="Qwen/Qwen3-ASR-1.7B", temperature=0.7, | ||
| 31 | - # api_key='no_key') | ||
| 32 | - | ||
| 33 | - videos_dir = rf"R:\wdx\202604\20260420_download_football_video\finished" | ||
| 34 | - for video_name in sorted(os.listdir(videos_dir)): | ||
| 35 | - replays_dir = os.path.join(videos_dir, video_name, 'replays') | ||
| 36 | - replay_videos_dir = os.path.join(replays_dir, 'videos') | ||
| 37 | - replay_goals_dir = os.path.join(replays_dir, 'goals') | ||
| 38 | - replay_asr_dir = os.path.join(replays_dir, 'asr') | ||
| 39 | - | ||
| 40 | - for replay_video_name in sorted(os.listdir(replay_videos_dir)): | ||
| 41 | - replay_video_path = os.path.join(replay_videos_dir, replay_video_name) | ||
| 42 | - replay_goals_path = os.path.join(replay_goals_dir, f"{replay_video_name}.json") | ||
| 43 | - replay_asr_path = os.path.join(replay_asr_dir, f"{replay_video_name}.json") | ||
| 44 | - if os.path.exists(replay_goals_path): | ||
| 45 | - print("skip", replay_video_path) | ||
| 46 | - continue | ||
| 47 | - # asr_text = qwen_asr.asr(replay_video_path, cache_path=replay_asr_path) | ||
| 48 | - event_json = fbrv.video_event(replay_video_path, None, cache_path=replay_goals_path) | ||
| 49 | - print(replay_video_name) | ||
| 50 | - print(event_json) | ||
| 51 | - | ||
| 52 | -def one_video_test(video_path): | ||
| 53 | - fbrv = FootballReplayVideoEvent(base_url="http://192.168.1.59:11434/v1", model="Qwen3.6-35B-A3B-UD-Q8_K_XL.gguf", temperature=0.7) | ||
| 54 | - event_json = fbrv.video_event(video_path, None) | ||
| 55 | - print(event_json) | ||
| 56 | - | ||
| 57 | -if __name__ == '__main__': | ||
| 58 | - # one_video_test(rf"D:\Code\py260417\src\llm_demo\lc_t\ftb\replay_1.mp4") | ||
| 59 | - 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") |
-
Please register or login to post a comment