| | |
| | | import numpy as np |
| | | from PIL import Image |
| | | |
| | | MODULE_DIRECTORY = str(Path(__file__).resolve().parent) |
| | | if MODULE_DIRECTORY not in sys.path: |
| | | sys.path.insert(0, MODULE_DIRECTORY) |
| | | |
| | | from transcribe_media import ( |
| | | MEDIA_DECODE_THREADS, |
| | | MEDIA_FILTER_THREADS, |
| | | MediaHostGuardError, |
| | | _subprocess_creation_flags, |
| | | media_host_guard, |
| | | ) |
| | | |
| | | |
| | | SCAN_MAX_WIDTH = 320 |
| | | FEATURE_WIDTH = 64 |
| | |
| | | DEDUPE_HASH_DISTANCE = 7 |
| | | EDGE_THRESHOLD = 18 |
| | | CONTENT_SCORE_TOLERANCE = 0.012 |
| | | PALETTE_BINS_PER_CHANNEL = 8 |
| | | PALETTE_BIN_COUNT = PALETTE_BINS_PER_CHANNEL ** 3 |
| | | SEQUENCE_THEME_COVERAGE = 0.12 |
| | | SEQUENCE_THEME_MAX_GAP = 1 |
| | | SEQUENCE_THEME_MIN_MARKERS = 8 |
| | | SEQUENCE_THEME_MIN_SHARE = 0.50 |
| | | SEQUENCE_THEME_MIN_DENSITY = 0.80 |
| | | PROCESS_WAIT_SECONDS = 5.0 |
| | | STDERR_TAIL_BYTES = 8192 |
| | | |
| | |
| | | """Expected, user-facing extraction failure.""" |
| | | |
| | | |
| | | class PartialFrameError(SlideExtractionError): |
| | | """A rawvideo stream ended in the middle of one frame.""" |
| | | |
| | | |
| | | class HardwareDecodePathError(SlideExtractionError): |
| | | """The NVDEC decoder/transfer pipe did not yield a complete frame stream.""" |
| | | |
| | | |
| | | @dataclass(frozen=True) |
| | | class VideoInfo: |
| | | width: int |
| | | height: int |
| | | duration: float | None |
| | | codec_name: str | None = None |
| | | pixel_format: str | None = None |
| | | |
| | | |
| | | @dataclass(frozen=True) |
| | | class ScanResult: |
| | | pages: tuple[LogicalPage, ...] |
| | | sample_count: int |
| | | stable_candidate_count: int |
| | | classification_counts: tuple[tuple[str, int], ...] |
| | | decoder: str | None |
| | | |
| | | |
| | | @dataclass(frozen=True) |
| | |
| | | sample_index: int |
| | | relative_seconds: float |
| | | gray: np.ndarray |
| | | palette_histogram: np.ndarray |
| | | perceptual_hash: int |
| | | content_score: float |
| | | sharpness: float |
| | |
| | | class PageCandidate: |
| | | timestamp: float |
| | | feature: np.ndarray |
| | | palette_histogram: np.ndarray |
| | | perceptual_hash: int |
| | | content_score: float |
| | | sharpness: float |
| | |
| | | class LogicalPage: |
| | | logical_anchor_feature: np.ndarray |
| | | current_feature: np.ndarray |
| | | current_palette: np.ndarray |
| | | current_hash: int |
| | | current_timestamp: float |
| | | current_score: float |
| | |
| | | if not chunks: |
| | | return None |
| | | actual = size - remaining |
| | | raise SlideExtractionError( |
| | | raise PartialFrameError( |
| | | f"rawvideo 半帧 EOF:期望 {size} bytes,实际 {actual} bytes" |
| | | ) |
| | | chunks.append(chunk) |
| | | remaining -= len(chunk) |
| | | return b"".join(chunks) |
| | | |
| | | |
| | | def _attach_cleanup_note(error: BaseException, detail: str) -> None: |
| | | error.add_note(f"清理诊断:{detail}") |
| | | if isinstance(error, HardwareDecodePathError): |
| | | setattr(error, "_media_cleanup_failed", True) |
| | | |
| | | |
| | | def _tail(path: Path, limit: int = STDERR_TAIL_BYTES) -> str: |
| | |
| | | stdout=subprocess.DEVNULL, |
| | | stderr=stderr_handle, |
| | | shell=False, |
| | | creationflags=_subprocess_creation_flags(), |
| | | ) |
| | | return_code = process.wait() |
| | | if return_code != 0: |
| | |
| | | "-select_streams", |
| | | "v:0", |
| | | "-show_entries", |
| | | "stream=index,width,height,codec_type:format=duration", |
| | | "stream=index,width,height,codec_name,pix_fmt,codec_type:format=duration", |
| | | "-of", |
| | | "json", |
| | | "-o", |
| | |
| | | stream = streams[0] |
| | | width = int(stream["width"]) |
| | | height = int(stream["height"]) |
| | | codec_name = str(stream.get("codec_name") or "").strip().lower() or None |
| | | pixel_format = str(stream.get("pix_fmt") or "").strip().lower() or None |
| | | duration_raw = (payload.get("format") or {}).get("duration") |
| | | duration = float(duration_raw) if duration_raw not in (None, "N/A") else None |
| | | except (OSError, ValueError, TypeError, KeyError, IndexError, json.JSONDecodeError) as exc: |
| | | raise SlideExtractionError(f"无法解析第一条视频流信息:{exc}") from exc |
| | | if width <= 0 or height <= 0: |
| | | raise SlideExtractionError("第一条视频流宽高无效") |
| | | return VideoInfo(width=width, height=height, duration=duration) |
| | | return VideoInfo( |
| | | width=width, |
| | | height=height, |
| | | duration=duration, |
| | | codec_name=codec_name, |
| | | pixel_format=pixel_format, |
| | | ) |
| | | |
| | | |
| | | def _scan_size(info: VideoInfo) -> tuple[int, int]: |
| | |
| | | return width, height |
| | | |
| | | |
| | | def _palette_histogram(rgb_array: np.ndarray) -> np.ndarray: |
| | | """Return a normalized 8x8x8 RGB histogram without retaining the RGB frame.""" |
| | | if rgb_array.ndim != 3 or rgb_array.shape[2] != 3 or rgb_array.size == 0: |
| | | raise SlideExtractionError("颜色主题特征要求非空 RGB 图像") |
| | | if rgb_array.dtype != np.uint8: |
| | | raise SlideExtractionError("颜色主题特征要求 uint8 RGB 图像") |
| | | quantized = rgb_array >> 5 |
| | | indexes = ( |
| | | quantized[:, :, 0].astype(np.int16) * PALETTE_BINS_PER_CHANNEL ** 2 |
| | | + quantized[:, :, 1].astype(np.int16) * PALETTE_BINS_PER_CHANNEL |
| | | + quantized[:, :, 2].astype(np.int16) |
| | | ) |
| | | histogram = np.bincount(indexes.reshape(-1), minlength=PALETTE_BIN_COUNT).astype( |
| | | np.float32 |
| | | ) |
| | | histogram /= float(indexes.size) |
| | | return histogram |
| | | |
| | | |
| | | def _gray_feature(rgb: bytes, width: int, height: int, index: int) -> FrameFeature: |
| | | array = np.frombuffer(rgb, dtype=np.uint8).reshape((height, width, 3)) |
| | | palette_histogram = _palette_histogram(array) |
| | | with Image.fromarray(array, mode="RGB") as image: |
| | | feature_height = max(1, round(height * FEATURE_WIDTH / width)) |
| | | gray_image = image.convert("L").resize( |
| | |
| | | sample_index=index, |
| | | relative_seconds=float(index), |
| | | gray=gray, |
| | | palette_histogram=palette_histogram, |
| | | perceptual_hash=perceptual_hash, |
| | | content_score=float(edge_density + 0.25 * contrast), |
| | | sharpness=sharpness, |
| | |
| | | return PageCandidate( |
| | | timestamp=best.relative_seconds, |
| | | feature=best.gray, |
| | | palette_histogram=best.palette_histogram.copy(), |
| | | perceptual_hash=best.perceptual_hash, |
| | | content_score=best.content_score, |
| | | sharpness=best.sharpness, |
| | |
| | | if self._animation(candidate, recent): |
| | | if candidate.content_score >= recent.current_score - CONTENT_SCORE_TOLERANCE: |
| | | recent.current_feature = candidate.feature |
| | | recent.current_palette = candidate.palette_histogram.copy() |
| | | recent.current_hash = candidate.perceptual_hash |
| | | recent.current_timestamp = candidate.timestamp |
| | | recent.current_score = candidate.content_score |
| | |
| | | LogicalPage( |
| | | logical_anchor_feature=candidate.feature.copy(), |
| | | current_feature=candidate.feature, |
| | | current_palette=candidate.palette_histogram.copy(), |
| | | current_hash=candidate.perceptual_hash, |
| | | current_timestamp=candidate.timestamp, |
| | | current_score=candidate.content_score, |
| | |
| | | return "new_page" |
| | | |
| | | |
| | | def _scan_command(ffmpeg: str, source: Path, scan_width: int, scan_height: int) -> list[str]: |
| | | filter_graph = ( |
| | | f"setpts=PTS-STARTPTS,fps=fps=1:start_time=0," |
| | | f"scale={scan_width}:{scan_height}:flags=bilinear" |
| | | def _validated_page_palettes(pages: Sequence[LogicalPage]) -> np.ndarray: |
| | | palettes: list[np.ndarray] = [] |
| | | for index, page in enumerate(pages, 1): |
| | | palette = np.asarray(page.current_palette) |
| | | if palette.shape != (PALETTE_BIN_COUNT,): |
| | | raise SlideExtractionError( |
| | | f"第 {index} 个稳定候选颜色主题维度错误:{palette.shape}" |
| | | ) |
| | | if not np.all(np.isfinite(palette)) or np.any(palette < 0): |
| | | raise SlideExtractionError(f"第 {index} 个稳定候选颜色主题包含非法数值") |
| | | total = float(np.sum(palette, dtype=np.float64)) |
| | | if not math.isclose(total, 1.0, rel_tol=1e-4, abs_tol=1e-4): |
| | | raise SlideExtractionError( |
| | | f"第 {index} 个稳定候选颜色主题未归一化:sum={total:.6f}" |
| | | ) |
| | | palettes.append(palette.astype(np.float32, copy=False)) |
| | | return np.stack(palettes) |
| | | |
| | | |
| | | def select_main_sequence(pages: Sequence[LogicalPage]) -> list[LogicalPage]: |
| | | """Conservatively keep the dominant contiguous visual-theme sequence.""" |
| | | if not pages: |
| | | raise SlideExtractionError("主课件序列过滤没有稳定候选页面") |
| | | palettes = _validated_page_palettes(pages) |
| | | total_pages = len(pages) |
| | | best_score: tuple[int, int, float, int, int] | None = None |
| | | best_bounds: tuple[int, int] | None = None |
| | | |
| | | for color_bin in range(PALETTE_BIN_COUNT): |
| | | marker_indexes = np.flatnonzero( |
| | | palettes[:, color_bin] >= SEQUENCE_THEME_COVERAGE |
| | | ).tolist() |
| | | if not marker_indexes: |
| | | continue |
| | | groups: list[list[int]] = [] |
| | | current_group = [int(marker_indexes[0])] |
| | | for marker_index in marker_indexes[1:]: |
| | | marker_index = int(marker_index) |
| | | if marker_index - current_group[-1] <= SEQUENCE_THEME_MAX_GAP + 1: |
| | | current_group.append(marker_index) |
| | | else: |
| | | groups.append(current_group) |
| | | current_group = [marker_index] |
| | | groups.append(current_group) |
| | | |
| | | for group in groups: |
| | | marker_count = len(group) |
| | | start, end = group[0], group[-1] |
| | | span = end - start + 1 |
| | | share = marker_count / total_pages |
| | | density = marker_count / span |
| | | if ( |
| | | marker_count < SEQUENCE_THEME_MIN_MARKERS |
| | | or share < SEQUENCE_THEME_MIN_SHARE |
| | | or density < SEQUENCE_THEME_MIN_DENSITY |
| | | ): |
| | | continue |
| | | coverage_sum = float( |
| | | np.sum(palettes[start : end + 1, color_bin], dtype=np.float64) |
| | | ) |
| | | score = (marker_count, span, coverage_sum, -start, -color_bin) |
| | | if best_score is None or score > best_score: |
| | | best_score = score |
| | | best_bounds = (start, end) |
| | | |
| | | if best_bounds is None: |
| | | return list(pages) |
| | | start, end = best_bounds |
| | | if start == 0 and end == total_pages - 1: |
| | | return list(pages) |
| | | return list(pages[start : end + 1]) |
| | | |
| | | |
| | | def _decoder_for_codec( |
| | | codec_name: str | None, |
| | | pixel_format: str | None = None, |
| | | ) -> str | None: |
| | | del pixel_format # evidence only; codec decides whether the single NVDEC attempt is made |
| | | return {"h264": "h264_cuvid", "hevc": "hevc_cuvid"}.get(codec_name or "") |
| | | |
| | | |
| | | def _scan_command( |
| | | ffmpeg: str, |
| | | source: Path, |
| | | scan_width: int, |
| | | scan_height: int, |
| | | decoder: str | None = None, |
| | | ) -> list[str]: |
| | | filters = [] |
| | | if decoder is not None: |
| | | filters.extend(("hwdownload", "format=nv12")) |
| | | filters.extend( |
| | | ( |
| | | "setpts=PTS-STARTPTS", |
| | | "fps=fps=1:start_time=0", |
| | | f"scale={scan_width}:{scan_height}:flags=bilinear", |
| | | ) |
| | | ) |
| | | return [ |
| | | command = [ |
| | | ffmpeg, |
| | | "-hide_banner", |
| | | "-loglevel", |
| | | "error", |
| | | "-i", |
| | | str(source), |
| | | ] |
| | | if decoder is not None: |
| | | command.extend( |
| | | ( |
| | | "-xerror", |
| | | "-hwaccel", |
| | | "cuda", |
| | | "-hwaccel_output_format", |
| | | "cuda", |
| | | "-c:v", |
| | | decoder, |
| | | ) |
| | | ) |
| | | command.extend( |
| | | ( |
| | | "-threads", |
| | | str(MEDIA_DECODE_THREADS), |
| | | "-filter_threads", |
| | | str(MEDIA_FILTER_THREADS), |
| | | "-filter_complex_threads", |
| | | str(MEDIA_FILTER_THREADS), |
| | | "-i", |
| | | str(source), |
| | | ) |
| | | ) |
| | | command.extend( |
| | | ( |
| | | "-map", |
| | | "0:v:0", |
| | | "-vf", |
| | | filter_graph, |
| | | ",".join(filters), |
| | | "-pix_fmt", |
| | | "rgb24", |
| | | "-f", |
| | | "rawvideo", |
| | | "-", |
| | | ] |
| | | ) |
| | | ) |
| | | return command |
| | | |
| | | |
| | | def scan_video(source: Path, staging: Path, info: VideoInfo) -> list[LogicalPage]: |
| | | def _scan_video_once( |
| | | source: Path, |
| | | staging: Path, |
| | | info: VideoInfo, |
| | | *, |
| | | decoder: str | None, |
| | | ) -> ScanResult: |
| | | scan_width, scan_height = _scan_size(info) |
| | | frame_bytes = scan_width * scan_height * 3 |
| | | stderr_path = staging / ".scan.stderr.log" |
| | | path_label = decoder or "cpu" |
| | | stderr_path = staging / f".scan-{path_label}.stderr.log" |
| | | stderr_handle: BinaryIO | None = None |
| | | process: subprocess.Popen[bytes] | None = None |
| | | active_error: BaseException | None = None |
| | | detector = StableSegmentDetector() |
| | | classifier = PageClassifier() |
| | | samples = 0 |
| | | stable_candidates = 0 |
| | | classifications: dict[str, int] = {} |
| | | try: |
| | | stderr_handle = stderr_path.open("wb") |
| | | process = subprocess.Popen( |
| | | _scan_command(_which_or_error("ffmpeg"), source, scan_width, scan_height), |
| | | stdin=subprocess.DEVNULL, |
| | | stdout=subprocess.PIPE, |
| | | stderr=stderr_handle, |
| | | shell=False, |
| | | try: |
| | | stderr_handle = stderr_path.open("wb") |
| | | except OSError as exc: |
| | | raise SlideExtractionError(f"FFmpeg 稳定帧扫描打开 stderr 失败:{exc}") from exc |
| | | command = _scan_command( |
| | | _which_or_error("ffmpeg"), |
| | | source, |
| | | scan_width, |
| | | scan_height, |
| | | decoder, |
| | | ) |
| | | try: |
| | | process = subprocess.Popen( |
| | | command, |
| | | stdin=subprocess.DEVNULL, |
| | | stdout=subprocess.PIPE, |
| | | stderr=stderr_handle, |
| | | shell=False, |
| | | creationflags=_subprocess_creation_flags(), |
| | | ) |
| | | except BaseException as exc: |
| | | if not isinstance(exc, Exception): |
| | | raise |
| | | if decoder is not None: |
| | | raise HardwareDecodePathError( |
| | | f"{decoder} 启动失败:{exc}" |
| | | ) from exc |
| | | raise SlideExtractionError(f"受限 CPU 扫描启动失败:{exc}") from exc |
| | | assert process.stdout is not None |
| | | while True: |
| | | raw = read_exact(process.stdout, frame_bytes) |
| | | try: |
| | | raw = read_exact(process.stdout, frame_bytes) |
| | | except PartialFrameError as exc: |
| | | if decoder is not None: |
| | | raise HardwareDecodePathError(f"{decoder} 输出半帧:{exc}") from exc |
| | | raise |
| | | if raw is None: |
| | | break |
| | | candidate = detector.add(_gray_feature(raw, scan_width, scan_height, samples)) |
| | | if candidate is not None: |
| | | classifier.consume(candidate) |
| | | stable_candidates += 1 |
| | | classification = classifier.consume(candidate) |
| | | classifications[classification] = classifications.get(classification, 0) + 1 |
| | | samples += 1 |
| | | if samples % 300 == 0: |
| | | if info.duration and info.duration > 0: |
| | |
| | | print(f"扫描进度:{samples} 秒", flush=True) |
| | | candidate = detector.flush() |
| | | if candidate is not None: |
| | | classifier.consume(candidate) |
| | | process.stdout.close() |
| | | stable_candidates += 1 |
| | | classification = classifier.consume(candidate) |
| | | classifications[classification] = classifications.get(classification, 0) + 1 |
| | | try: |
| | | process.stdout.close() |
| | | except OSError as exc: |
| | | raise SlideExtractionError(f"FFmpeg 稳定帧扫描关闭 stdout 失败:{exc}") from exc |
| | | return_code = process.wait() |
| | | if return_code != 0: |
| | | stderr_handle.flush() |
| | | detail = _tail(stderr_path) |
| | | suffix = f":{detail}" if detail else "" |
| | | raise SlideExtractionError(f"FFmpeg 稳定帧扫描失败(exit={return_code}){suffix}") |
| | | message = f"FFmpeg 稳定帧扫描失败(exit={return_code}){suffix}" |
| | | if decoder is not None: |
| | | raise HardwareDecodePathError(f"{decoder} {message}") |
| | | raise SlideExtractionError(message) |
| | | except BaseException as exc: |
| | | active_error = exc |
| | | cleanup_error = _attempt_reap(process) if process is not None else None |
| | | if not isinstance(exc, Exception): |
| | | if cleanup_error is not None: |
| | | _attach_cleanup_note(exc, f"子进程回收失败:{cleanup_error}") |
| | | raise |
| | | if cleanup_error is not None: |
| | | raise SlideExtractionError( |
| | | f"FFmpeg 稳定帧扫描失败:{exc};子进程清理失败:{cleanup_error}" |
| | | ) from exc |
| | | if isinstance(exc, SlideExtractionError): |
| | | raise |
| | | raise SlideExtractionError(f"FFmpeg 稳定帧扫描失败:{exc}") from exc |
| | | _attach_cleanup_note(exc, f"子进程回收失败:{cleanup_error}") |
| | | raise |
| | | finally: |
| | | if stderr_handle is not None: |
| | | try: |
| | |
| | | raise SlideExtractionError( |
| | | f"FFmpeg 稳定帧扫描关闭 stderr 失败:{close_error}" |
| | | ) from close_error |
| | | _attach_cleanup_note(active_error, f"关闭 stderr 失败:{close_error}") |
| | | if samples == 0: |
| | | if decoder is not None: |
| | | raise HardwareDecodePathError(f"{decoder} 未输出任何完整扫描帧") |
| | | raise SlideExtractionError("第一条视频流没有可扫描画面") |
| | | if not classifier.pages: |
| | | return ScanResult( |
| | | pages=tuple(classifier.pages), |
| | | sample_count=samples, |
| | | stable_candidate_count=stable_candidates, |
| | | classification_counts=tuple(sorted(classifications.items())), |
| | | decoder=decoder, |
| | | ) |
| | | |
| | | |
| | | def scan_video(source: Path, staging: Path, info: VideoInfo) -> list[LogicalPage]: |
| | | decoder = _decoder_for_codec(info.codec_name, info.pixel_format) |
| | | if decoder is None: |
| | | print( |
| | | "警告:视频编码没有 H.264/H.265 NVDEC 映射," |
| | | "使用 4 解码线程/2 滤镜线程的受限 CPU。", |
| | | file=sys.stderr, |
| | | flush=True, |
| | | ) |
| | | result = _scan_video_once(source, staging, info, decoder=None) |
| | | path_label = "受限 CPU" |
| | | else: |
| | | try: |
| | | result = _scan_video_once(source, staging, info, decoder=decoder) |
| | | path_label = f"NVDEC/CUDA ({decoder})" |
| | | except HardwareDecodePathError as exc: |
| | | if getattr(exc, "_media_cleanup_failed", False): |
| | | raise |
| | | print( |
| | | f"警告:NVDEC 不可用({decoder}):{exc};改用 4 解码线程/2 滤镜线程的受限 CPU。", |
| | | file=sys.stderr, |
| | | flush=True, |
| | | ) |
| | | result = _scan_video_once(source, staging, info, decoder=None) |
| | | path_label = "受限 CPU(NVDEC 一次回退)" |
| | | print( |
| | | f"扫描解码路径:{path_label};解码线程={MEDIA_DECODE_THREADS};" |
| | | f"滤镜线程={MEDIA_FILTER_THREADS}", |
| | | flush=True, |
| | | ) |
| | | if not result.pages: |
| | | raise SlideExtractionError("未发现持续至少 3 个采样点的稳定页面") |
| | | return classifier.pages |
| | | return list(result.pages) |
| | | |
| | | |
| | | def _extract_command(ffmpeg: str, source: Path, timestamp: float, destination: Path) -> list[str]: |
| | |
| | | "error", |
| | | "-ss", |
| | | f"{timestamp:.3f}", |
| | | "-threads", |
| | | str(MEDIA_DECODE_THREADS), |
| | | "-i", |
| | | str(source), |
| | | "-map", |
| | |
| | | flush=True, |
| | | ) |
| | | stage = "扫描稳定页面" |
| | | pages = scan_video(source, staging, info) |
| | | print(f"稳定页面:{len(pages)}", flush=True) |
| | | candidates = scan_video(source, staging, info) |
| | | pages = select_main_sequence(candidates) |
| | | print( |
| | | f"稳定候选:{len(candidates)};主课件页面:{len(pages)}", |
| | | flush=True, |
| | | ) |
| | | stage = "提取原分辨率 PNG" |
| | | png_paths = extract_original_pages(source, staging, info, pages) |
| | | stage = "生成合并 PDF" |
| | |
| | | def main(argv: Sequence[str] | None = None) -> int: |
| | | args = build_parser().parse_args(argv) |
| | | try: |
| | | extract_slides(args.video, args.output) |
| | | with media_host_guard(): |
| | | extract_slides(args.video, args.output) |
| | | return 0 |
| | | except KeyboardInterrupt: |
| | | print("错误:用户中断,暂存已清理。", file=sys.stderr) |
| | | return 130 |
| | | except SlideExtractionError as exc: |
| | | except (SlideExtractionError, MediaHostGuardError) as exc: |
| | | print(f"错误:{exc}", file=sys.stderr) |
| | | return 2 |
| | | |