视频帧精简:剔除无效帧,按帧率智能抽帧?

摘要:视频帧图片批量精简工具:清理无效帧 + 按帧率抽帧 在处理视频抽帧后的图片数据集时,我们常面临两个问题:一是存在大量空白无效图片(比如纯黑纯白帧、文件体积极小的损坏图),二是原始帧率过高(如 30fps)导致图
视频帧图片批量精简工具:清理无效帧 + 按帧率抽帧 在处理视频抽帧后的图片数据集时,我们常面临两个问题:一是存在大量空白 / 无效图片(比如纯黑 / 纯白帧、文件体积极小的损坏图),二是原始帧率过高(如 30fps)导致图片数量冗余。今天分享的这个工具能一站式解决这两个问题,自动清理无效帧并按指定帧率精简图片,大幅降低数据集体积且不丢失关键信息。 一、工具核心用途 扫描指定目录下所有含图片帧的子目录,批量处理; 智能清理无效图片:删除文件体积过小(可自定义阈值)或图像内容近乎纯色(灰度方差判定)的空白帧; 按帧率规则精简图片:比如从 30fps 抽帧结果中,按 2fps 保留(每 15 帧保留 1 张),按需调整原始 / 目标帧率; 支持 “干运行” 模式:仅统计删除 / 保留数量,不实际删除文件,安全验证策略; 输出详细处理报告:含各目录原始 / 保留 / 删除数量、总压缩率、耗时等。 二、核心实现方式 工具基于 Python 开发,核心逻辑分为三步,整体流程清晰易懂: 1. 前置准备:目录扫描与依赖加载 扫描指定根目录,过滤掉无关目录(如标注工具目录),仅保留包含图片的子目录; 依赖 Pillow 处理图片、numpy 计算图像方差,缺失时自动提示安装。 2. 无效图片判定(双重过滤) 文件体积过滤:读取文件大小,低于指定字节阈值(默认 5KB)直接判定为无效; 图像内容过滤:将图片转为灰度图,计算像素值方差 —— 方差越低说明像素越均匀(越接近纯色),低于阈值则判定为空白帧。 3. 帧率精简逻辑 从图片文件名中提取帧号(匹配第一段数字); 计算抽帧步长(步长 = 原始帧率 / 目标帧率),仅保留帧号为步长整数倍的图片,其余删除; 对无法提取帧号的图片,默认保留(避免误删)。 4. 流程管控 逐目录处理,输出每一步操作(删除原因、失败报错); 处理完成后汇总所有目录数据,计算总保留数、删除数、压缩率。 三、依赖安装方式 工具依赖 Python 第三方库 Pillow(图片处理)和 numpy(数值计算),执行以下命令即可完成安装: bash 运行 pip install Pillow numpy 注:建议在虚拟环境中安装,避免依赖冲突;Python 版本推荐 3.7 及以上。 四、工具使用说明 1. 基础用法 直接运行脚本(默认处理脚本所在目录,30fps→2fps,5KB 最小文件,方差阈值 50): bash 运行 python streamline_frames.py 2. 自定义参数 bash 运行 # 指定根目录、原始帧率25、目标帧率1、最小文件10KB、方差阈值30 python streamline_frames.py --root /path/to/frames --fps-orig 25 --fps-keep 1 --min-size 10240 --var-threshold 30 # 干运行(仅统计,不删除文件) python streamline_frames.py --root /path/to/frames --dry-run 3. 参数说明 表格 参数 说明 默认值 --root 图片根目录 脚本所在目录 --fps-orig 原始帧率 30 --fps-keep 目标帧率 2 --min-size 最小文件字节数(低于则删除) 5120(5KB) --var-threshold 灰度方差阈值(低于则判定为空白) 50.0 --dry-run 干运行模式(仅统计) 关闭 五、输出示例 ============================================================ 视频帧图片精简 & 无效图清理工具 根目录:/data/frames 子目录数量:2 原始帧率:30 fps -> 目标帧率:2 fps 无效图判定:文件 < 5120 B 或 灰度方差 < 50.0 ============================================================ ============================================================ 目录:scene_01 共 300 张图片 策略:每 15 帧保留 1 张(30fps -> 2fps),同时清除无效图片 ============================================================ [DEL-SMALL] frame_001.jpg (1024 B) [DEL-BLANK] frame_005.jpg (方差过低,疑似无内容) [DEL] frame_002.jpg (多余帧) ... [OK] 保留:20 张 [DEL] 删除无效图片:5 张 [DEL] 删除多余帧: 275 张 # 汇总报告 ============================================================ 处理完成 - 汇总报告 ============================================================ 目录 原始 保留 删无效 删多余 ----------------------------------------------------------------- scene_01 300 20 5 275 scene_02 285 19 3 263 ----------------------------------------------------------------- 合计 585 39 8 538 原始总计:585 张 -> 精简后:39 张 共删除:546 张(无效图 8 + 多余帧 538) 压缩率:93.3% 耗时:2.5s 六、完整代码 点击查看代码 # -*- coding: utf-8 -*- """ streamline_frames.py ==================== 功能: 1. 扫描指定根目录下的所有子目录(含图片帧) 2. 检测并删除无效/空白图片(文件过小 或 图像内容近乎纯色) 3. 按 30fps -> 2fps 的规则精简帧(每 15 帧保留 1 张) 用法: python streamline_frames.py [--root ROOT_DIR] [--fps-orig 30] [--fps-keep 2] [--min-size 5120] [--var-threshold 50] [--dry-run] """ import os import re import sys import argparse import time from pathlib import Path try: from PIL import Image import numpy as np except ImportError: print("[ERROR] 缺少依赖库,请先执行:pip install Pillow numpy") sys.exit(1) def is_blank_image(path, var_threshold): """判断图片是否为近乎纯色(无内容)的空白图。""" try: with Image.open(path) as img: gray = img.convert("L") arr = np.array(gray, dtype=np.float32) variance = float(np.var(arr)) return variance < var_threshold except Exception as e: print(" [WARN] 无法读取图片 %s: %s,将视为无效图片" % (path.name, e)) return True def parse_frame_number(filename): """从文件名中提取帧号(取第一段数字)。""" stem = Path(filename).stem m = re.search(r'(\d+)', stem) return int(m.group(1)) if m else None def process_directory(dir_path, fps_orig, fps_keep, min_size, var_threshold, dry_run): """处理单个子目录,返回统计信息字典。""" jpg_files = sorted( [f for f in dir_path.iterdir() if f.is_file() and f.suffix.lower() in ('.jpg', '.jpeg', '.png')], key=lambda f: f.name ) if not jpg_files: return {"dir": dir_path.name, "total": 0, "kept": 0, "deleted_blank": 0, "deleted_sparse": 0, "errors": 0} step = fps_orig // fps_keep total = len(jpg_files) deleted_blank = 0 deleted_sparse = 0 kept = 0 errors = 0 sep = "=" * 60 print("\n" + sep) print("目录:%s 共 %d 张图片" % (dir_path.name, total)) print("策略:每 %d 帧保留 1 张(%dfps -> %dfps),同时清除无效图片" % (step, fps_orig, fps_keep)) print(sep) for f in jpg_files: frame_num = parse_frame_number(f.name) file_size = f.stat().st_size # 步骤一:文件过小 -> 删除 if file_size < min_size: print(" [DEL-SMALL] %s (%d B)" % (f.name, file_size)) if not dry_run: try: f.unlink() deleted_blank += 1 except Exception as e: print(" [ERROR] 删除失败: %s" % e) errors += 1 else: deleted_blank += 1 continue # 步骤二:图像内容近乎纯色 -> 删除 if is_blank_image(f, var_threshold): print(" [DEL-BLANK] %s (方差过低,疑似无内容)" % f.name) if not dry_run: try: f.unlink() deleted_blank += 1 except Exception as e: print(" [ERROR] 删除失败: %s" % e) errors += 1 else: deleted_blank += 1 continue # 步骤三:帧率精简 if frame_num is None: kept += 1 continue if frame_num % step == 0: kept += 1 else: if not dry_run: try: f.unlink() deleted_sparse += 1 except Exception as e: print(" [ERROR] 删除失败: %s" % e) errors += 1 else: deleted_sparse += 1 print("\n [OK] 保留:%d 张" % kept) print(" [DEL] 删除无效图片:%d 张" % deleted_blank) print(" [DEL] 删除多余帧: %d 张" % deleted_sparse) if errors: print(" [ERR] 删除失败: %d 张" % errors) return { "dir": dir_path.name, "total": total, "kept": kept, "deleted_blank": deleted_blank, "deleted_sparse": deleted_sparse, "errors": errors, } def main(): parser = argparse.ArgumentParser(description="视频帧图片精简 & 无效图清理工具") parser.add_argument("--root", type=str, default=str(Path(__file__).parent), help="图片根目录(默认脚本所在目录)") parser.add_argument("--fps-orig", type=int, default=30, help="原始帧率(默认 30)") parser.add_argument("--fps-keep", type=int, default=2, help="目标帧率(默认 2)") parser.add_argument("--min-size", type=int, default=5120, help="文件最小字节,低于此值视为无效(默认 5120=5KB)") parser.add_argument("--var-threshold", type=float, default=50.0, help="图像灰度方差阈值,低于此值视为无内容(默认 50)") parser.add_argument("--dry-run", action="store_true", help="仅统计,不实际删除文件") args = parser.parse_args() root = Path(args.root) if not root.is_dir(): print("[ERROR] 根目录不存在:%s" % root) sys.exit(1) skip_dirs = {"标注要求及说明", "labelImg", "Xanylabeling", ".workbuddy", "__pycache__"} target_dirs = [] for d in root.iterdir(): if not d.is_dir() or d.name in skip_dirs: continue try: has_img = any(f.suffix.lower() in ('.jpg', '.jpeg', '.png') for f in d.iterdir() if f.is_file()) if has_img: target_dirs.append(d) except PermissionError: pass if not target_dirs: print("[INFO] 未找到含图片的子目录,程序退出。") return sep = "#" * 60 print("\n" + sep) print(" 视频帧图片精简 & 无效图清理工具") print(" 根目录:%s" % root) print(" 子目录数量:%d" % len(target_dirs)) print(" 原始帧率:%d fps -> 目标帧率:%d fps" % (args.fps_orig, args.fps_keep)) print(" 无效图判定:文件 < %d B 或 灰度方差 < %.1f" % (args.min_size, args.var_threshold)) if args.dry_run: print(" [!] DRY-RUN 模式:仅统计,不实际删除") print(sep) t0 = time.time() all_stats = [] for d in sorted(target_dirs): stats = process_directory( dir_path=d, fps_orig=args.fps_orig, fps_keep=args.fps_keep, min_size=args.min_size, var_threshold=args.var_threshold, dry_run=args.dry_run, ) all_stats.append(stats) elapsed = time.time() - t0 print("\n" + sep) print(" 处理完成 - 汇总报告") print(sep) total_orig = sum(s["total"] for s in all_stats) total_kept = sum(s["kept"] for s in all_stats) total_blank = sum(s["deleted_blank"] for s in all_stats) total_sparse = sum(s["deleted_sparse"] for s in all_stats) total_del = total_blank + total_sparse print(" %-35s %6s %6s %7s %7s" % ("目录", "原始", "保留", "删无效", "删多余")) print(" " + "-" * 65) for s in all_stats: print(" %-35s %6d %6d %7d %7d" % ( s["dir"], s["total"], s["kept"], s["deleted_blank"], s["deleted_sparse"])) print(" " + "-" * 65) print(" %-35s %6d %6d %7d %7d" % ( "合计", total_orig, total_kept, total_blank, total_sparse)) print("") print(" 原始总计:%d 张 -> 精简后:%d 张" % (total_orig, total_kept)) print(" 共删除:%d 张(无效图 %d + 多余帧 %d)" % (total_del, total_blank, total_sparse)) if total_orig > 0: print(" 压缩率:%.1f%% 耗时:%.1fs" % ((1 - total_kept/total_orig)*100, elapsed)) print(sep + "\n") if __name__ == "__main__": main()