diff --git a/doco/src/doco/video_split.py b/doco/src/doco/video_split.py index de95c0e..68fc4ed 100644 --- a/doco/src/doco/video_split.py +++ b/doco/src/doco/video_split.py @@ -176,28 +176,35 @@ def extract_audio( # ======================================================================== -def is_blank_frame(image_path: Path, debug: bool = False) -> Tuple[bool, float]: +def is_blank_frame(image_path: Path, debug: bool = False) -> Tuple[bool, float, int]: """ 判断是否为空白帧(留气口黑画面) - 算法:转灰度,统计亮度 > 200(接近白)的像素数量 - 如果占比 < 0.5%,判定为空白帧 + 双条件检测(两条件必须同时满足才算"有字幕"): + - 条件 A: max_brightness >= 240 (有接近纯白的像素) + - 条件 B: white_pixel_ratio >= 0.005 (白像素占比 >= 0.5%) + + 任一条件不满足 → is_blank=True 适用于:黑底白字场景(军事科技栏目) - 返回: (is_blank, white_ratio) + 返回: (is_blank, white_pixel_ratio, max_brightness) """ img = Image.open(image_path).convert("L") arr = np.array(img) - white_ratio = float(np.mean(arr > BLANK_FRAME_BRIGHTNESS_THRESHOLD)) - is_blank = white_ratio < BLANK_FRAME_WHITE_PIXEL_RATIO + max_brightness = int(np.max(arr)) + # 阈值固定用 240,与条件 A 一致 + white_pixel_ratio = float(np.mean(arr > BLANK_FRAME_BRIGHTNESS_THRESHOLD)) + + # 双条件:同时满足才算有字幕 + has_subtitle = (max_brightness >= BLANK_FRAME_BRIGHTNESS_THRESHOLD) and (white_pixel_ratio >= BLANK_FRAME_WHITE_PIXEL_RATIO) + is_blank = not has_subtitle if debug: frame_idx = image_path.stem - print(f"[debug] {frame_idx}, white_ratio={white_ratio:.6f}, is_blank={is_blank}") + print(f"[debug] {frame_idx}, max_brightness={max_brightness}, white_pixel_ratio={white_pixel_ratio:.6f}, is_blank={is_blank}") - max_brightness = int(np.max(arr)) - return is_blank, white_ratio, max_brightness + return is_blank, white_pixel_ratio, max_brightness # ======================================================================== @@ -638,6 +645,33 @@ def split_video( print(f"[video_split] dry-run keyframes.json 写入: {keyframes_path}") print("[video_split] 请检查 frames/ 目录下的关键帧图片,确认裁切框位置正确") + # ---- 自检:随机抽 3 张最终关键帧 PNG,验证像素数据与 CSV 一致 ---- + keyframe_files = sorted(frames_dir.glob("frame_*.png")) + import random + sample_frames = random.sample(keyframe_files, min(3, len(keyframe_files))) + for frame_file in sample_frames: + is_blank_check, white_ratio_check, max_brightness_check = is_blank_frame(frame_file, debug=False) + frame_idx_str = frame_file.stem # e.g. "frame_0001" + # CSV 里 frame_index = 1 对应 frame_0001 + frame_idx_in_csv = int(frame_idx_str.split("_")[1]) + if frame_idx_in_csv in frame_analysis: + csv_max_bright = None + csv_white_ratio = None + # 从 CSV 原始行找对应 frame_index 的数据 + with open(debug_csv_path, "r", encoding="utf-8") as csvf: + reader = csv.DictReader(csvf) + for row in reader: + if int(row["frame_index"]) == frame_idx_in_csv: + csv_max_bright = int(row["max_brightness"]) + csv_white_ratio = float(row["white_pixel_ratio"]) + break + if csv_max_bright is not None: + if abs(csv_max_bright - max_brightness_check) > 1 or abs(csv_white_ratio - white_ratio_check) > 0.0001: + print(f"[WARNING] 自检失败: {frame_file.name} 像素数据与 CSV 不一致! CSV: max_brightness={csv_max_bright}, white_ratio={csv_white_ratio:.6f}; 实际重读: max_brightness={max_brightness_check}, white_ratio={white_ratio_check:.6f}") + raise RuntimeError(f"自检失败: {frame_file.name} 像素数据与 CSV 记录不一致,已中止") + else: + print(f"[自检] {frame_file.name}: max_brightness={max_brightness_check}, white_ratio={white_ratio_check:.6f} ✓") + return { "keyframes_path": str(keyframes_path), "audio_path": str(audio_path), diff --git a/doco/tests/test_video_split.py b/doco/tests/test_video_split.py index 872ee4f..a882c76 100644 --- a/doco/tests/test_video_split.py +++ b/doco/tests/test_video_split.py @@ -17,7 +17,7 @@ from doco.src.video_split import ( hamming_distance, format_timestamp, build_b_manuscript, - compute_phash, + is_blank_frame, ) @@ -139,3 +139,67 @@ class TestKeyframesJson: assert loaded["phash_threshold"] == 8 assert len(loaded["keyframes"]) == 1 assert loaded["keyframes"][0]["frame_index"] == 0 + + +class TestIsBlankFrame: + """is_blank_frame 双条件检测单元测试""" + + def test_pure_black_frame(self, tmp_path): + """全 0 纯黑图:max_brightness=0,white_ratio=0,is_blank=True""" + from PIL import Image + img_path = tmp_path / "black.png" + img = Image.new("L", (100, 100), 0) # 全黑 + img.save(img_path) + + is_blank, white_ratio, max_brightness = is_blank_frame(img_path) + assert is_blank is True + assert max_brightness == 0 + assert white_ratio == 0.0 + + def test_subtitle_frame(self, tmp_path): + """有少量白像素(亮度255,占~1%)的图:is_blank=False""" + from PIL import Image + import numpy as np + img_path = tmp_path / "subtitle.png" + arr = np.zeros((100, 100), dtype=np.uint8) + # 1% 白像素(亮度255),其余黑(亮度0) + arr[:100, :10] = 255 + img = Image.fromarray(arr, mode="L") + img.save(img_path) + + is_blank, white_ratio, max_brightness = is_blank_frame(img_path) + assert is_blank is False + assert max_brightness == 255 + assert 0.005 <= white_ratio <= 0.02 # ~1% + + def test_max_brightness_ok_but_ratio_too_low(self, tmp_path): + """max_brightness>=240 但 white_ratio<0.005 → is_blank=True""" + from PIL import Image + import numpy as np + img_path = tmp_path / "few_pixels.png" + arr = np.zeros((100, 100), dtype=np.uint8) + # 只有 0.1% 白像素(不够 0.5%),但 max_brightness=255 + arr[:10, :1] = 255 + img = Image.fromarray(arr, mode="L") + img.save(img_path) + + is_blank, white_ratio, max_brightness = is_blank_frame(img_path) + assert is_blank is True + assert max_brightness == 255 + assert white_ratio < 0.005 + + def test_ratio_ok_but_max_brightness_too_low(self, tmp_path): + """white_ratio>=0.005 但 max_brightness<240 → is_blank=True""" + from PIL import Image + import numpy as np + img_path = tmp_path / "dim_pixels.png" + arr = np.zeros((100, 100), dtype=np.uint8) + # 1% 像素亮度=220(不够240阈值),其余黑 + arr[:100, :10] = 220 + img = Image.fromarray(arr, mode="L") + img.save(img_path) + + is_blank, white_ratio, max_brightness = is_blank_frame(img_path) + assert is_blank is True + assert max_brightness == 220 + assert white_ratio >= 0.005