Merge branch 'develop_fifa_2026_replay_match' of ssh://gitlab.cmvideo.cn:7999/pr…
…oduction_capacity/clip/aigc-group/aigc-embedding-service into develop_fifa_2026_replay_match # Conflicts: # src/football_replay_match/cfg/config.yaml # src/football_replay_match/core/api.py
Showing
7 changed files
with
821 additions
and
8 deletions
| 1 | +input_kafka: | ||
| 2 | + servers: prod-kafka:9092 | ||
| 3 | + group_id: ai_match_service_prod | ||
| 4 | + topic: football_replay_match_req | ||
| 5 | + # username: xxx | ||
| 6 | + # password: xxx | ||
| 7 | + | ||
| 8 | +output_kafka: | ||
| 9 | + servers: prod-kafka:9092 | ||
| 10 | + topic: football_replay_match_resp | ||
| 11 | + | ||
| 12 | +cache_dir: /data/cache | ||
| 13 | +llm_base_url: http://prod-llm:11434 | ||
| 14 | +llm_model: qwen3.6:35b-a3b-q8_0 | ||
| 15 | +llm_temperature: 0.7 |
| 1 | -from aabd.base.cfg_loader import load_yaml_by_file_with_env | ||
| 2 | -from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars | 1 | +import os |
| 3 | from pathlib import Path | 2 | from pathlib import Path |
| 4 | from logging import getLogger | 3 | from logging import getLogger |
| 4 | + | ||
| 5 | +from aabd.base.cfg_loader import load_yaml_by_file_with_env | ||
| 6 | +from aabd.base.enhance_dict import EnhanceDict, read_prefixed_env_vars | ||
| 7 | + | ||
| 5 | logger = getLogger(__name__) | 8 | logger = getLogger(__name__) |
| 6 | cfg_dir = Path(__file__).parent.absolute() / 'cfg' | 9 | cfg_dir = Path(__file__).parent.absolute() / 'cfg' |
| 7 | -settings = EnhanceDict(load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix())) | 10 | + |
| 11 | +# 加载基础配置 | ||
| 12 | +base_config = load_yaml_by_file_with_env((cfg_dir / 'config.yaml').as_posix()) | ||
| 13 | + | ||
| 14 | +# 根据 ENV 环境变量加载环境特定配置并合并(如 config.dev.yaml / config.prod.yaml) | ||
| 15 | +env = os.getenv('ENV', 'dev') | ||
| 16 | +env_file = cfg_dir / f'config.{env}.yaml' | ||
| 17 | +if env_file.exists(): | ||
| 18 | + env_config = load_yaml_by_file_with_env(env_file.as_posix()) | ||
| 19 | + if isinstance(base_config, dict) and isinstance(env_config, dict): | ||
| 20 | + for key, value in env_config.items(): | ||
| 21 | + if key in base_config and isinstance(base_config[key], dict) and isinstance(value, dict): | ||
| 22 | + base_config[key].update(value) | ||
| 23 | + else: | ||
| 24 | + base_config[key] = value | ||
| 25 | + logger.info(f"已加载环境配置: {env_file.name}") | ||
| 26 | + | ||
| 27 | +settings = EnhanceDict(base_config) | ||
| 8 | settings.update(read_prefixed_env_vars('FRM_')) | 28 | settings.update(read_prefixed_env_vars('FRM_')) |
| 9 | 29 | ||
| 10 | logger.info(f'Settings: {settings}') | 30 | logger.info(f'Settings: {settings}') |
| 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 | + | ||
| 1 | class Video2Frame: | 27 | class Video2Frame: |
| 2 | def __init__(self, cache_dir): | 28 | def __init__(self, cache_dir): |
| 3 | - pass | 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) | ||
| 4 | 34 | ||
| 5 | def to_frames(self, url, start_utc, end_utc, fps, roi=None, max_px_area=None) -> list: | 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: | ||
| 6 | pass | 84 | pass |
| 7 | 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 | + | ||
| 8 | 236 | ||
| 9 | def frames2content(frames): | 237 | def frames2content(frames): |
| 10 | - return | ||
| 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 |
| @@ -55,9 +55,11 @@ def kafka_message_iterator_thread(message_iterator, config, callback_func): | @@ -55,9 +55,11 @@ def kafka_message_iterator_thread(message_iterator, config, callback_func): | ||
| 55 | for message in message_iterator: | 55 | for message in message_iterator: |
| 56 | try: | 56 | try: |
| 57 | json_message = json.loads(message) | 57 | json_message = json.loads(message) |
| 58 | - replay_match_event(json_message) | ||
| 59 | - logger.info(message) | ||
| 60 | - except: | 58 | + result = replay_match_event(json_message) |
| 59 | + task_id = json_message.get("id") | ||
| 60 | + callback_func(task_id, json.dumps(result, ensure_ascii=False)) | ||
| 61 | + logger.info(f"Processed task_id={task_id}, result={result}") | ||
| 62 | + except Exception: | ||
| 61 | logger.exception(f"Error processing message: {message}") | 63 | logger.exception(f"Error processing message: {message}") |
| 62 | 64 | ||
| 63 | 65 |
| @@ -96,3 +96,21 @@ class FootballReplayVideoEvent: | @@ -96,3 +96,21 @@ class FootballReplayVideoEvent: | ||
| 96 | with open(cache_path, 'w', encoding='utf-8') as f: | 96 | with open(cache_path, 'w', encoding='utf-8') as f: |
| 97 | f.write(result) | 97 | f.write(result) |
| 98 | return result_json | 98 | return result_json |
| 99 | + | ||
| 100 | + def video_event_for_contents(self, contents: list, asr_text: str = '无', cache_path=None): | ||
| 101 | + if cache_path is not None and os.path.exists(cache_path): | ||
| 102 | + with open(cache_path, 'r', encoding='utf-8') as f: | ||
| 103 | + return json.loads(f.read()) | ||
| 104 | + system_message = SystemMessage(content=req_prompt) | ||
| 105 | + video_message = HumanMessage(content=contents) | ||
| 106 | + asr_message = HumanMessage(content=f"解说内容:{asr_text}") | ||
| 107 | + result = self.model.invoke([system_message, video_message, asr_message]).content | ||
| 108 | + try: | ||
| 109 | + result_json = json.loads(result) | ||
| 110 | + except json.JSONDecodeError: | ||
| 111 | + result_json = json.loads(result.replace("```json", "").replace("```", "")) | ||
| 112 | + if cache_path is not None: | ||
| 113 | + os.makedirs(Path(cache_path).parent, exist_ok=True) | ||
| 114 | + with open(cache_path, 'w', encoding='utf-8') as f: | ||
| 115 | + f.write(result) | ||
| 116 | + return result_json |
-
Please register or login to post a comment