Spaces:
Running
Running
Download collector/core/encoder_probe.py from Stouching-Robotics/DataFlowing: direct link, hf CLI and curl.
- Browser
- Download file 14.5 kB
-
https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/collector/core/encoder_probe.py
- Command line
-
hf download hf://spaces/Stouching-Robotics/DataFlowing/collector/core/encoder_probe.py
-
curl -L -o encoder_probe.py https://huggingface.co/spaces/Stouching-Robotics/DataFlowing/resolve/main/collector/core/encoder_probe.py
14.5 kB
| """ | |
| 编码器探针 —— 录制编码器自动选择(v1.0.9:录制直出 HEVC)。 | |
| 纯函数模块(无 Qt 依赖),供 EgoDataWriter 在录制启动前调用(发生在 | |
| pipeline 的 _start_async 后台线程内,写盘线程在其返回后才启动,探针 | |
| 不卡 UI、不影响写入节奏): | |
| - 候选 ffmpeg 二进制枚举(imageio 静态 → PATH,-version 自检) | |
| - 编码器能力探测(-encoders)与速度探针(null muxer 微型编码) | |
| - 编码器选择(auto: hevc_nvenc → libx265 速度达标 → libx264 回退) | |
| 与参数映射 | |
| 选择结果进程内缓存(同一进程内机器不变不重探;负结果也缓存)。探针 | |
| 同步执行、最坏 ~10-20s;所有探针异常按「该编码器不可用」降级处理。 | |
| """ | |
| from __future__ import annotations | |
| import dataclasses | |
| import shutil | |
| import subprocess | |
| import threading | |
| import time | |
| from typing import Callable, Dict, List, Optional | |
| from config import settings | |
| # ── 进程内缓存 ──────────────────────────────────────── | |
| _FFMPEG_CACHE: Optional[List[str]] = None | |
| _ENCODER_CACHE: Dict[tuple, bool] = {} | |
| _SELECT_CACHE: Dict[tuple, Optional["EncoderChoice"]] = {} | |
| class EncoderChoice: | |
| """一次编码器选择的结果。""" | |
| encoder: str # "hevc_nvenc" | "libx265" | "libx264" | |
| codec: str # "HEVC" | "H.264" | |
| crf: int # 本选择实际用的档位(x264 用回退档) | |
| args: list # ffmpeg 编码参数(不含 -c:v) | |
| ffmpeg: str # 采用的 ffmpeg 二进制 | |
| label: str # UI 显示名,如 "HEVC (hevc_nvenc)" | |
| probe: dict # 探针明细(写 metadata.json) | |
| selected_by: str # settings.RECORD_VIDEO_ENCODER 原值 | |
| # ═══════════════════════════════════════════════════════ | |
| # ffmpeg 二进制枚举 / 编码器能力 | |
| # ═══════════════════════════════════════════════════════ | |
| def list_working_ffmpegs() -> List[str]: | |
| """候选 ffmpeg 二进制(imageio 静态 → PATH),经 -version 自检。 | |
| 全部自检失败时,仍把 imageio 静态二进制作为最后兜底返回(与旧 | |
| _get_ffmpeg 口径一致:它已在录制段被证明可用)。 | |
| """ | |
| global _FFMPEG_CACHE | |
| if _FFMPEG_CACHE is not None: | |
| return list(_FFMPEG_CACHE) | |
| candidates: List[str] = [] | |
| imageio_exe = None | |
| try: | |
| import imageio_ffmpeg | |
| imageio_exe = imageio_ffmpeg.get_ffmpeg_exe() | |
| candidates.append(imageio_exe) | |
| except Exception: | |
| pass | |
| for name in ("ffmpeg", "ffmpeg.exe"): | |
| p = shutil.which(name) | |
| if p and p not in candidates: | |
| candidates.append(p) | |
| working = [] | |
| for exe in candidates: | |
| try: | |
| r = subprocess.run([exe, "-version"], capture_output=True, | |
| timeout=10) | |
| if r.returncode == 0: | |
| working.append(exe) | |
| except Exception: | |
| continue | |
| if not working and imageio_exe: | |
| working.append(imageio_exe) # 兜底:录段历史证明可用 | |
| _FFMPEG_CACHE = working | |
| return list(working) | |
| def ffmpeg_has_encoder(ffmpeg: str, encoder: str) -> bool: | |
| """-encoders 输出里是否含该编码器(结果缓存)。""" | |
| key = (ffmpeg, encoder) | |
| if key in _ENCODER_CACHE: | |
| return _ENCODER_CACHE[key] | |
| found = False | |
| try: | |
| r = subprocess.run([ffmpeg, "-hide_banner", "-encoders"], | |
| capture_output=True, timeout=30) | |
| text = ((r.stdout or b"") + (r.stderr or b"")).decode("utf-8", "ignore") | |
| found = encoder in text.split() | |
| except Exception: | |
| found = False | |
| _ENCODER_CACHE[key] = found | |
| return found | |
| # ═══════════════════════════════════════════════════════ | |
| # 参数映射 / 微型探针 | |
| # ═══════════════════════════════════════════════════════ | |
| def encoder_args(encoder: str, crf: Optional[int] = None) -> List[str]: | |
| """编码器参数映射(HEVC 一律带 hvc1 容器标记)。""" | |
| if crf is None: | |
| crf = settings.RECORD_VIDEO_CRF | |
| table = { | |
| "hevc_nvenc": ["-rc", "vbr", "-cq", str(crf), "-preset", "p4", | |
| "-tag:v", "hvc1"], | |
| "libx265": ["-crf", str(crf), "-preset", "veryfast", "-tag:v", "hvc1"], | |
| "libx264": ["-preset", "ultrafast", | |
| "-crf", str(settings.RECORD_VIDEO_X264_CRF)], | |
| } | |
| return list(table[encoder]) | |
| def _kill(p: subprocess.Popen) -> None: | |
| try: | |
| p.kill() | |
| except Exception: | |
| pass | |
| try: | |
| p.wait(timeout=3) | |
| except Exception: | |
| pass | |
| def _feed_raw(proc: subprocess.Popen, frame_bytes: bytes, frames: int) -> None: | |
| try: | |
| for _ in range(frames): | |
| proc.stdin.write(frame_bytes) | |
| proc.stdin.flush() | |
| finally: | |
| try: | |
| proc.stdin.close() | |
| except Exception: | |
| pass | |
| def probe_nvenc(ffmpeg: str, width: int, height: int, fps: float, | |
| crf: Optional[int] = None, timeout: int = 10) -> bool: | |
| """NVENC 微型探针:~1s 编码(null muxer)确认 GPU 可用。""" | |
| frames = max(3, int(fps)) | |
| cmd = [ffmpeg, "-y", "-f", "rawvideo", "-pix_fmt", "bgr24", | |
| "-s", f"{width}x{height}", "-r", str(fps), "-i", "-", | |
| "-frames:v", str(frames), | |
| "-c:v", "hevc_nvenc", *encoder_args("hevc_nvenc", crf), | |
| "-f", "null", "-"] | |
| try: | |
| p = subprocess.Popen(cmd, stdin=subprocess.PIPE, | |
| stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL) | |
| _feed_raw(p, bytes(width * height * 3), frames) | |
| return p.wait(timeout=timeout) == 0 | |
| except Exception: | |
| return False | |
| def probe_x265(ffmpeg: str, width: int, height: int, fps: float, | |
| n_streams: int, frames: Optional[int] = None, | |
| timeout: Optional[int] = None) -> dict: | |
| """x265 速度探针:并行 n 流各编 frames 帧 → null,返回每流实测 fps。 | |
| n_streams = 本会话视频流数(封顶 ENCODER_PROBE_MAX_STREAMS)。全部 | |
| 流并行跑满同一墙钟,每流 fps = frames / 总耗时 —— 与写盘线程每 tick | |
| 串行写全部槽的背压口径近似一致。合成帧为全零帧(编码最快),1.5× | |
| 门槛余量即为此乐观偏差所设。 | |
| """ | |
| if frames is None: | |
| frames = settings.ENCODER_PROBE_FRAME_COUNT | |
| if timeout is None: | |
| timeout = settings.ENCODER_PROBE_TIMEOUT_S | |
| n = max(1, min(int(n_streams), settings.ENCODER_PROBE_MAX_STREAMS)) | |
| frame_bytes = bytes(width * height * 3) | |
| cmd_base = [ffmpeg, "-y", "-f", "rawvideo", "-pix_fmt", "bgr24", | |
| "-s", f"{width}x{height}", "-r", str(fps), "-i", "-", | |
| "-frames:v", str(frames), | |
| "-c:v", "libx265", *encoder_args("libx265"), | |
| "-f", "null", "-"] | |
| procs: List[subprocess.Popen] = [] | |
| start = time.monotonic() | |
| ok = True | |
| try: | |
| for _ in range(n): | |
| procs.append(subprocess.Popen( | |
| cmd_base, stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL)) | |
| feeders = [threading.Thread(target=_feed_raw, args=(p, frame_bytes, frames), | |
| daemon=True) for p in procs] | |
| for t in feeders: | |
| t.start() | |
| for p in procs: | |
| p.wait(timeout=timeout) | |
| except subprocess.TimeoutExpired: | |
| ok = False | |
| for p in procs: | |
| if p.poll() is None: | |
| _kill(p) | |
| ok = False | |
| elif p.returncode != 0: | |
| ok = False | |
| elapsed = time.monotonic() - start | |
| if not ok or elapsed <= 0: | |
| return {"per_stream_fps": [], "min_fps": 0.0, "passed": False, | |
| "streams": n, "frames": frames, "elapsed_s": round(elapsed, 2)} | |
| per = [frames / elapsed] * n | |
| min_fps = min(per) | |
| threshold = fps * settings.ENCODER_X265_MIN_FPS_RATIO | |
| return {"per_stream_fps": [round(x, 1) for x in per], | |
| "min_fps": round(min_fps, 1), "passed": min_fps >= threshold, | |
| "threshold_fps": round(threshold, 1), | |
| "streams": n, "frames": frames, "elapsed_s": round(elapsed, 2)} | |
| def probe_depth_12bit(ffmpeg: str, timeout: int = 8) -> bool: | |
| """深度 12-bit 灰度编码能力探针:gray12le → libx265 → null。 | |
| x265 版本/构建差异下 gray12le(4:0:0,hevc Rext)未必可用; | |
| 单帧微型编码(32×32 → null)确认能力,结果进程内缓存。 | |
| 失败时调用方回落 FFV1 gray16le MKV(无损 16-bit 旧格式)。 | |
| """ | |
| key = (ffmpeg, "depth12") | |
| if key in _ENCODER_CACHE: | |
| return _ENCODER_CACHE[key] | |
| ok = False | |
| try: | |
| p = subprocess.Popen( | |
| [ffmpeg, "-y", "-f", "rawvideo", "-pix_fmt", "gray12le", | |
| "-s", "32x32", "-r", "30", "-i", "-", | |
| "-frames:v", "1", "-c:v", "libx265", | |
| "-x265-params", "qp=6:range=full", "-f", "null", "-"], | |
| stdin=subprocess.PIPE, stdout=subprocess.DEVNULL, | |
| stderr=subprocess.DEVNULL) | |
| _feed_raw(p, bytes(32 * 32 * 2), 1) | |
| ok = p.wait(timeout=timeout) == 0 | |
| except Exception: | |
| ok = False | |
| _ENCODER_CACHE[key] = ok | |
| return ok | |
| def find_depth_12bit_ffmpeg() -> Optional[str]: | |
| """候选二进制中第一个支持 gray12le 12-bit 编码的;全不支持返回 None。""" | |
| return next((b for b in list_working_ffmpegs() if probe_depth_12bit(b)), | |
| None) | |
| # ═══════════════════════════════════════════════════════ | |
| # 选择入口 | |
| # ═══════════════════════════════════════════════════════ | |
| def select_encoder(prefer: str, width: int, height: int, fps: float, | |
| n_streams: int, log: Optional[Callable[[str], None]] = None | |
| ) -> Optional[EncoderChoice]: | |
| """选择录制编码器。返回 EncoderChoice,全链失败返回 None。 | |
| prefer: settings.RECORD_VIDEO_ENCODER("auto"|"nvenc"|"x265"|"x264") | |
| auto 链: hevc_nvenc(探针可用) → libx265(速度达标) → libx264(兜底) | |
| 显式指定: 跳过速度门槛强制用该编码器;二进制不支持/探针失败时按 | |
| auto 链回退并播报原因。 | |
| """ | |
| if log is None: | |
| log = lambda msg: None | |
| cache_key = (prefer, width, height, fps, | |
| min(int(n_streams), settings.ENCODER_PROBE_MAX_STREAMS)) | |
| if cache_key in _SELECT_CACHE: | |
| return _SELECT_CACHE[cache_key] | |
| bins = list_working_ffmpegs() | |
| if not bins: | |
| log("[编码器] 未找到可用 ffmpeg 二进制") | |
| _SELECT_CACHE[cache_key] = None | |
| return None | |
| def find_with(enc: str) -> Optional[str]: | |
| return next((b for b in bins if ffmpeg_has_encoder(b, enc)), None) | |
| def make(enc: str, ffmpeg: str, probe: Optional[dict] = None) -> EncoderChoice: | |
| is_hevc = enc != "libx264" | |
| crf = settings.RECORD_VIDEO_CRF if is_hevc else settings.RECORD_VIDEO_X264_CRF | |
| return EncoderChoice( | |
| encoder=enc, | |
| codec="HEVC" if is_hevc else "H.264", | |
| crf=crf, | |
| args=encoder_args(enc), | |
| ffmpeg=ffmpeg, | |
| label=("HEVC (%s)" % enc) if is_hevc else "H.264 (libx264)", | |
| probe=probe or {}, | |
| selected_by=prefer) | |
| def auto_chain(why: Optional[str] = None) -> Optional[EncoderChoice]: | |
| if why: | |
| log(f"[编码器] {why},按 auto 链回退") | |
| exe = find_with("hevc_nvenc") | |
| if exe and probe_nvenc(exe, width, height, fps, | |
| settings.RECORD_VIDEO_CRF): | |
| log("[编码器] hevc_nvenc 探针通过 → HEVC 硬件编码") | |
| return make("hevc_nvenc", exe, {"kind": "nvenc", "ok": True}) | |
| if exe: | |
| log("[编码器] hevc_nvenc 探针失败(GPU 不可用),跳过") | |
| exe = find_with("libx265") | |
| if exe: | |
| probe = probe_x265(exe, width, height, fps, n_streams) | |
| if probe["passed"]: | |
| log(f"[编码器] libx265 实测 {probe['min_fps']:.0f} fps/流 " | |
| f"(门槛 {probe['threshold_fps']:.0f}) → HEVC 软编") | |
| return make("libx265", exe, probe) | |
| log(f"[编码器] libx265 实测 {probe['min_fps']:.0f} fps/流 " | |
| f"(门槛 {probe['threshold_fps']:.0f}) → 不达标") | |
| exe = find_with("libx264") | |
| if exe: | |
| log(f"[编码器] 回退 H.264 (libx264 ultrafast, " | |
| f"CRF{settings.RECORD_VIDEO_X264_CRF})") | |
| return make("libx264", exe) | |
| return None | |
| if prefer == "nvenc": | |
| exe = find_with("hevc_nvenc") | |
| if exe and probe_nvenc(exe, width, height, fps, | |
| settings.RECORD_VIDEO_CRF): | |
| log("[编码器] 显式 nvenc:hevc_nvenc 探针通过") | |
| choice = make("hevc_nvenc", exe, {"kind": "nvenc", "ok": True}) | |
| else: | |
| choice = auto_chain("显式 nvenc 但 hevc_nvenc 探针失败") | |
| elif prefer == "x265": | |
| exe = find_with("libx265") | |
| if exe: | |
| log("[编码器] 显式 x265:跳过速度门槛,强制 HEVC 软编") | |
| choice = make("libx265", exe) | |
| else: | |
| choice = auto_chain("显式 x265 但无二进制支持 libx265") | |
| elif prefer == "x264": | |
| exe = find_with("libx264") | |
| if exe: | |
| log("[编码器] 显式 x264:跳过探针,直接 H.264") | |
| choice = make("libx264", exe) | |
| else: | |
| choice = auto_chain("显式 x264 但无二进制支持 libx264") | |
| else: # auto | |
| choice = auto_chain() | |
| _SELECT_CACHE[cache_key] = choice | |
| return choice | |