视频数据集转帧序列这件事,说简单也简单,几行脚本就能跑通;说麻烦也麻烦,真正动手做过的都知道,坑几乎全藏在"划分"和"命名"这两个环节里。我前后处理过不下十个动作识别类的数据集,HMDB51 是其中被问得最多的一个——它足够经典,51 个类别、每类至少 101 段视频,规模适中,适合拿来练手,也适合作为新项目的数据基线。但很多人第一次处理它的时候,往往卡在三个地方:抽帧抽出来的图片数量对不上、训练验证测试的划分比例和官方标注对不齐、以及抽完帧之后目录结构乱成一锅粥,后面写 DataLoader 的时候又要返工。
这篇内容就是围绕"把视频数据集转成帧序列,再切成训练集、验证集、测试集"这条完整链路展开的。我会以 HMDB51 为具体例子,把每一步为什么这么做、参数怎么定、目录怎么组织、有哪些容易翻车的地方都讲清楚。不管你是刚接触动作识别的新手,还是已经跑过几个数据集、想把手上的流程规范化一下的老手,应该都能从里面找到能直接抄作业的部分。核心关键词就三个:视频抽帧、数据集划分、帧序列组织,全文都围绕它们转。
1. 先想清楚:为什么动作识别要先抽帧再划分
1.1 视频直接喂进模型的问题在哪
很多人第一反应是,视频不就是一串帧吗,为什么不能直接把 mp4 文件丢给模型?这个问题我在带新人的时候被问过无数次。答案其实不复杂:绝大多数动作识别模型,无论是早期的双流网络、C3D,还是后来的 I3D、SlowFast,它们在数据入口处期待的输入都是"帧的集合"或者"帧组成的张量",而不是一个封装好的视频容器。视频容器里除了图像帧,还混着音频轨、时间戳、编码信息、关键帧索引这些东西,解码本身就是一个不确定性的来源——不同机器上的解码器版本不一样,解出来的帧可能有细微差异,甚至帧数都对不上。
抽帧的本质,是把"解码"这个动作提前、一次性做完,把结果固化成一张张独立的图片文件。这样做有几个直接好处。第一是可复现,同一批图片在任何机器上读出来都是一样的,不会因为解码库版本变化导致训练结果漂移。第二是可缓存,图片读取比视频解码快得多,训练时数据管道的瓶颈会明显缓解。第三是可灵活采样,动作识别里常用的稀疏采样、密集采样、时序片段采样,都是建立在"我能随机访问任意一帧"这个前提上的,视频文件做不到这一点。
还有一个容易被忽略的点:抽帧之后,数据集的体积和文件数量会暴涨。HMDB51 原始视频大概 2GB 左右,抽完帧之后轻松上到几十 GB、几十万个文件。这不是坏事,但它意味着你在划分和存储的时候必须提前规划,否则后面移动文件、拷贝数据集会非常痛苦。
1.2 划分这件事,官方标注和自定义划分的区别
HMDB51 官方其实自带了三组训练测试划分(train/test split),每组里训练集和测试集的比例大约是 70:30,而且同一段视频在不同组里的归属是不一样的,这是为了做交叉验证用的。但官方划分里没有单独的验证集,很多人在复现论文的时候会自己从训练集里再切一部分出来当验证集。
这里就出现了一个关键选择:你是要严格follow官方划分,还是自己重新按比例切?我的建议是分场景。如果你是要和某篇论文的指标做对比,那必须用官方划分,否则数字没有可比性。如果你是做一个新项目、或者只是想跑通流程,那自己按 70/15/15 或者 80/10/10 切更省事,也更符合一般工程习惯。
需要特别提醒的是,划分的粒度必须是"视频级",不能是"帧级"。这一点极其重要。如果你先抽帧、再把所有帧打乱按比例分,那同一段视频的帧会同时出现在训练集和测试集里,模型在测试时等于见过训练数据,指标会虚高得离谱。这个错误我见过不止一个人犯,而且犯了之后很难自查出来,因为流程跑得通、loss 也在降,只是最终指标好得不像话。正确的做法永远是:先确定哪些视频属于训练、哪些属于验证、哪些属于测试,然后再按视频去抽帧、去归位。
1.3 整体流程的骨架
把上面这些想清楚之后,整条链路的骨架就出来了,我习惯把它拆成五步:
- 盘点原始视频,确认类别目录结构和每个类别的样本数;
- 生成视频级的划分清单(train/val/test 三个列表,里面是视频的相对路径);
- 按清单逐视频抽帧,帧存到对应类别的对应划分目录下;
- 校验抽帧结果,检查帧数、空目录、损坏文件;
- 固化目录结构和清单文件,供后续训练脚本读取。
这个顺序不能乱。尤其是"先划分、后抽帧"这一点,很多人图省事反过来做,结果就是上面说的数据泄漏。下面几节我会按这个骨架逐步展开,把每一步的细节和坑都摊开讲。
2. 动手前的数据盘点与目录约定
2.1 HMDB51 的原始结构长什么样
HMDB51 解压之后,通常是一个根目录,下面按类别分成 51 个子目录,每个子目录里是若干 avi 文件。比如brush_hair/、cartwheel/、clap/这样的类别名,每个目录里文件名类似April_09_brush_hair_u_nm_np1_ba_goo_0.avi这种带一长串后缀的命名。文件名里其实编码了不少信息,比如动作名、镜头类型、是否稳定等,但对我们做抽帧和划分来说,真正有用的只有"它属于哪个类别"。
官方还附带几个标注文件,比如split_train_1.txt、split_test_1.txt之类,里面每行是"视频相对路径 + 划分标记"。这个格式值得留意,因为它其实就是一份现成的划分清单,如果你决定用官方划分,直接解析这些文件就行,不用自己重新生成。
我建议在动手之前先跑一段盘点脚本,把每个类别的视频数、总视频数、有没有空目录、有没有非 avi 文件都摸清楚。HMDB51 理论上每类至少 101 段,但实际下载的版本偶尔会有缺失,提前发现总比抽到一半报错强。
import os from collections import Counter root = "/data/HMDB51" exts = (".avi", ".mp4", ".mkv") counter = Counter() bad_dirs = [] for cls in sorted(os.listdir(root)): cls_dir = os.path.join(root, cls) if not os.path.isdir(cls_dir): continue vids = [f for f in os.listdir(cls_dir) if f.lower().endswith(exts)] counter[cls] = len(vids) if len(vids) == 0: bad_dirs.append(cls) print("类别数:", len(counter)) print("视频总数:", sum(counter.values())) print("样本数最少的类别:", counter.most_common()[-3:]) print("空目录:", bad_dirs)这段脚本跑完,你心里就有底了。如果发现某个类别明显偏少,要么是下载不全,要么是解压出错,先补齐再往下走。
2.2 抽帧后的目录结构怎么设计
目录结构这件事,看起来是小事,实际上决定了你后面写数据加载代码时的痛苦程度。我见过太多人抽完帧之后,所有图片平铺在一个大目录里,文件名靠视频名加帧号区分,结果写 Dataset 的时候要疯狂做字符串解析,既慢又容易出错。
我推荐的目录约定是这样的,按"划分 / 类别 / 视频名 / 帧文件"四级组织:
frames/ ├── train/ │ ├── brush_hair/ │ │ ├── April_09_brush_hair_u_nm_np1_ba_goo_0/ │ │ │ ├── 00001.jpg │ │ │ ├── 00002.jpg │ │ │ └── ... │ │ └── ... │ └── ... ├── val/ │ └── ...(结构同 train) └── test/ └── ...(结构同 train)这样组织的好处很直接:类别信息在路径里,视频边界在目录层级里,帧顺序在文件名里。写 Dataset 的时候,用glob或者os.walk扫一遍就能拿到"视频目录列表",每个视频目录里的图片排序后就是时序帧,完全不需要解析文件名。而且训练、验证、测试三套数据物理隔离,不会出现误读。
帧文件名我习惯用五位补零的数字,比如00001.jpg。补零是为了保证字符串排序和数值排序一致,否则10.jpg会排在2.jpg前面,时序就乱了。这个细节很小,但踩过的人都知道有多烦。
2.3 划分清单的格式与生成逻辑
划分清单我建议单独存成文本文件,每行一条记录,格式是"视频相对路径 + 空格 + 划分标记",和官方格式保持一致。这样做有两个好处:一是清单和帧数据解耦,将来想换划分比例,重新生成清单再重跑抽帧就行,不用改代码逻辑;二是清单本身可读、可 diff、可版本管理,出问题的时候一眼能看出哪条记录不对。
生成清单的逻辑,核心就是"按类别分层抽样"。不能把所有视频混在一起随机切,否则某些类别可能全被分到训练集,验证集里一个样本都没有,指标就没法看了。正确做法是每个类别内部独立按比例切,保证三个划分里每个类别都有样本。
import os, random root = "/data/HMDB51" out_dir = "./splits" os.makedirs(out_dir, exist_ok=True) ratios = {"train": 0.7, "val": 0.15, "test": 0.15} seed = 42 random.seed(seed) records = {"train": [], "val": [], "test": []} for cls in sorted(os.listdir(root)): cls_dir = os.path.join(root, cls) if not os.path.isdir(cls_dir): continue vids = sorted(f for f in os.listdir(cls_dir) if f.lower().endswith(".avi")) random.shuffle(vids) n = len(vids) n_train = int(n * ratios["train"]) n_val = int(n * ratios["val"]) for i, v in enumerate(vids): rel = os.path.join(cls, v) if i < n_train: records["train"].append(rel) elif i < n_train + n_val: records["val"].append(rel) else: records["test"].append(rel) for split, items in records.items(): with open(os.path.join(out_dir, f"{split}.txt"), "w") as f: for rel in items: f.write(rel + "\n") print(split, len(items))这里有个细节值得说:random.seed(42)固定随机种子,是为了让划分可复现。你今天切出来的清单,下周重跑还是同一份,这对实验对比很重要。另外int()是向下取整,样本少的类别可能出现验证集为 0 的情况,如果类别样本特别少,建议改成round()或者保证每个划分至少 1 个样本。
3. 抽帧环节的核心参数与实现
3.1 抽帧工具怎么选:OpenCV 还是 ffmpeg
抽帧工具主流就两个选择:Python 里的 OpenCV(cv2),或者命令行的 ffmpeg。两者都能干活,但适用场景不太一样,我一般这么权衡:
| 维度 | OpenCV (cv2) | ffmpeg |
|---|---|---|
| 上手难度 | 低,纯 Python 调用 | 中,需要拼命令行参数 |
| 速度 | 中等 | 快,底层优化好 |
| 帧率控制 | 靠代码跳帧,灵活 | 原生支持-vf fps= |
| 批量处理 | 需要自己写循环 | 一条命令处理整个目录 |
| 错误处理 | 异常可捕获,好定位 | 报错信息偏底层 |
| 依赖 | 需要装 opencv-python | 需要系统装 ffmpeg |
我的实际选择是:小规模、需要精细控制(比如按时间戳采样、按帧号跳帧)的场景用 OpenCV;大规模、只按固定帧率抽的场景用 ffmpeg。HMDB51 这种规模,两者都行,但如果你后面要处理更大的数据集,ffmpeg 的批量能力会省很多时间。
不过要注意,ffmpeg 的-vf fps=N是按"每秒抽 N 帧"来的,它和"每隔 K 帧抽一帧"是两种不同的采样逻辑。前者依赖视频原始帧率,后者不依赖。如果你的数据集里视频帧率不统一(HMDB51 里就有这种情况),用 fps 方式抽出来的帧数会参差不齐,用跳帧方式则相对可控。这一点在选工具的时候就要想清楚。
3.2 抽帧间隔怎么定:一个需要算清楚的参数
抽帧间隔是整个流程里最需要"算"的参数。抽太密,帧数爆炸,存储和读取都吃不消;抽太稀,动作的时序信息丢失,模型学不到东西。这里没有万能值,得结合视频帧率和动作时长来定。
拿 HMDB51 举例,大部分视频是 30fps 左右,单段视频时长从一两秒到十几秒不等。如果你每隔 10 帧抽一帧,等效采样率就是 3fps,一段 5 秒的视频大概能抽出 15 帧。这个密度对于识别"brush_hair""clap"这类动作是够的,但对于"cartwheel"这种快速动作可能偏稀。
我一般的经验法则是:保证每段视频抽出的帧数落在 16 到 64 之间。低于 16,时序建模基本没戏;高于 64,对大多数模型来说是冗余,而且存储压力大。按这个目标反推抽帧间隔:
目标帧数 ≈ 视频总帧数 / 抽帧间隔 抽帧间隔 ≈ 视频总帧数 / 目标帧数比如一段 150 帧的视频,想抽 30 帧左右,间隔就是 5。实际操作中我会先统计整个数据集的视频帧数分布,取中位数来定一个全局间隔,个别过长或过短的视频单独处理。这样比一刀切更合理。
还有一个替代思路是"固定抽 N 帧",不管视频多长都均匀抽 N 帧。这种做法在时序模型里很常见,好处是每个样本的帧数一致,batch 好组;坏处是短动作会被过度采样、长动作会被欠采样。如果你的模型对输入长度敏感,这个方式值得考虑。
3.3 用 OpenCV 实现逐视频抽帧
下面这段是我常用的抽帧函数,逻辑是读清单、逐视频抽帧、按约定目录存图。几个关键点我在注释里标了:
import os import cv2 def extract_frames(video_path, out_dir, interval=5, max_frames=64, quality=95): os.makedirs(out_dir, exist_ok=True) cap = cv2.VideoCapture(video_path) if not cap.isOpened(): return 0 idx = 0 saved = 0 while True: ret, frame = cap.read() if not ret: break if idx % interval == 0: # 文件名五位补零,保证排序正确 fname = os.path.join(out_dir, f"{saved+1:05d}.jpg") cv2.imwrite(fname, frame, [cv2.IMWRITE_JPEG_QUALITY, quality]) saved += 1 if saved >= max_frames: break idx += 1 cap.release() return saved这里有几个我踩过坑才加上的细节。第一,cap.isOpened()一定要判断,损坏的视频文件会在这里返回 False,不判断的话后面read()会一直返回空,循环直接空转。第二,max_frames上限很重要,有些视频异常长,不设上限会抽出上千帧,把磁盘撑爆。第三,JPEG 质量设 95 而不是默认值,是因为默认压缩在动作识别里会损失一些纹理细节,95 是个质量和体积的平衡点。
还有一个隐藏问题:OpenCV 读视频时,如果视频有旋转元数据(比如手机拍的竖屏视频),read()出来的帧可能是没旋转的。HMDB51 里这种情况少,但如果你混入了其他来源的视频,最好加一步判断,根据cap.get(cv2.CAP_PROP_ORIENTATION_META)做旋转校正。这个坑很隐蔽,抽出来的帧看着"歪"的,训练时模型学到的特征就全乱了。
3.4 用 ffmpeg 批量抽帧的写法
如果你决定用 ffmpeg,批量处理的思路是遍历清单,对每个视频调一次命令行。核心参数是-vf fps=或者-vf select=,前者按帧率抽,后者按帧号条件抽。按帧号跳帧的写法是这样的:
ffmpeg -i input.avi -vf "select='not(mod(n\,5))'" -vsync vfr -q:v 2 output_%05d.jpgselect='not(mod(n\,5))'的意思是"帧号 n 能被 5 整除时选中",等效于每隔 5 帧抽一帧。-vsync vfr是让输出帧率可变,避免 ffmpeg 自作主张补帧或丢帧。-q:v 2控制 JPEG 质量,数值越小质量越高,2 到 5 之间是比较合理的范围。
用 Python 调 ffmpeg 的话,用subprocess.run包一层,注意要捕获返回码,非零就记日志跳过。批量跑的时候建议加个进度打印,不然几百个视频跑起来你完全不知道进行到哪了。
提示:ffmpeg 的
-q:v和 OpenCV 的IMWRITE_JPEG_QUALITY不是同一套刻度,前者是 1-31 越小越好,后者是 0-100 越大越好,别搞混了。
4. 划分与抽帧的衔接:那些容易翻车的地方
4.1 数据泄漏:最隐蔽也最致命的错误
前面提过一次数据泄漏,这里我要再展开讲,因为它是这条链路里最值得警惕的问题。数据泄漏的典型表现是:训练指标和测试指标都很好,但模型换一批真实数据就崩。根因就是同一段视频的帧同时进了训练集和测试集。
为什么会发生?最常见的是"先抽帧、后随机划分"。有人抽完帧之后,把所有帧文件列成一个列表,random.shuffle一下按 8:2 切,看起来没问题,实际上同一段视频的几十帧被随机打散到了两边。模型在训练时见过这段视频的某些帧,测试时又见到同一段视频的其他帧,两者高度相似,指标自然虚高。
正确的做法前面说过,是"先划分视频、再按划分抽帧"。但还有一种更隐蔽的泄漏:如果你用了官方的三组划分做交叉验证,要注意每组划分里同一段视频的归属是不同的,千万别把三组混在一起用。我见过有人把三组划分的帧全抽到一个目录里,然后重新随机切,这就等于把官方的交叉验证设计彻底破坏了。
自查的方法很简单:抽完帧之后,检查训练集和测试集的视频目录名有没有交集。有交集就是泄漏,必须重来。
import os def list_videos(split_dir): vids = set() for cls in os.listdir(split_dir): cls_dir = os.path.join(split_dir, cls) if os.path.isdir(cls_dir): for v in os.listdir(cls_dir): vids.add(f"{cls}/{v}") return vids train_vids = list_videos("frames/train") test_vids = list_videos("frames/test") print("交集数量:", len(train_vids & test_vids))这段检查建议每次抽完帧都跑一遍,交集必须是 0。
4.2 类别不平衡在划分后的放大效应
HMDB51 虽然号称每类至少 101 段,但实际分布并不完全均匀,有些类别可能 100 出头,有些能到 200 多。按比例划分之后,这种不平衡会被继承到每个划分里。如果你的任务对类别平衡敏感,划分时可以考虑分层抽样加配额控制,保证每个划分里各类别比例尽量一致。
更麻烦的是小类别在验证集里可能只剩个位数样本,指标波动会很大。这种情况下我一般会做两件事:一是划分时对小类别保证验证集至少有 5 个样本,不够就从训练集里匀;二是评估时用宏平均而不是微平均,避免大类别主导指标。
4.3 抽帧失败与空目录的处理
抽帧过程中总会遇到几个失败案例:视频损坏、编码不支持、路径含特殊字符。这些如果不处理,会在对应位置留下空目录,后面训练时 DataLoader 扫到空目录会报错,或者更糟——静默跳过,导致实际训练样本比预期少。
我的处理方式是抽帧时记录一份失败清单,抽完之后统一检查空目录,把失败的和空的都记下来,人工确认后决定是补抽还是剔除。剔除的话要同步更新划分清单,保证清单和实际帧数据一致。
import os empty = [] for split in ["train", "val", "test"]: base = os.path.join("frames", split) for cls in os.listdir(base): cls_dir = os.path.join(base, cls) if not os.path.isdir(cls_dir): continue for vid in os.listdir(cls_dir): vid_dir = os.path.join(cls_dir, vid) if os.path.isdir(vid_dir) and len(os.listdir(vid_dir)) == 0: empty.append(f"{split}/{cls}/{vid}") print("空视频目录数:", len(empty)) for e in empty[:20]: print(e)空目录数量如果超过总数的 1%,就值得回头查一下是不是抽帧参数有问题,比如 interval 设得比视频总帧数还大,那自然一帧都抽不出来。
5. 抽帧结果的校验与目录固化
5.1 帧数分布检查:判断抽帧参数是否合理
抽完帧之后,第一件要做的事是统计每个视频的帧数分布。这个统计能直接告诉你抽帧参数定得对不对。理想情况下,帧数应该集中在你设定的目标区间内,比如 16 到 64。如果大量视频只有个位数帧,说明 interval 太大或者视频太短;如果大量视频顶到 max_frames 上限,说明 interval 太小或者上限设低了。
import os import statistics counts = [] for split in ["train", "val", "test"]: base = os.path.join("frames", split) for cls in os.listdir(base): cls_dir = os.path.join(base, cls) if not os.path.isdir(cls_dir): continue for vid in os.listdir(cls_dir): vid_dir = os.path.join(cls_dir, vid) if os.path.isdir(vid_dir): counts.append(len(os.listdir(vid_dir))) print("视频数:", len(counts)) print("帧数中位数:", statistics.median(counts)) print("帧数均值:", round(statistics.mean(counts), 1)) print("最少帧:", min(counts), "最多帧:", max(counts))我一般会看中位数和均值是否接近,如果差得远,说明分布有长尾,可能有异常视频。另外会单独看最少帧的那几个视频,确认是视频本身短还是抽帧出了问题。
5.2 图片完整性抽检
帧数对了不代表图片没问题。偶尔会出现写了一半的损坏 JPEG,或者全黑帧。全黑帧在动作识别里是真实存在的(视频开头结尾常有黑场),但如果比例过高就不正常。抽检的方式是随机采样一批图片,用 OpenCV 读一下,检查能否正常解码、尺寸是否一致、像素均值是否异常。
import cv2, random, os def check_images(base, sample=200): all_imgs = [] for root, _, files in os.walk(base): for f in files: if f.endswith(".jpg"): all_imgs.append(os.path.join(root, f)) random.seed(0) sample_imgs = random.sample(all_imgs, min(sample, len(all_imgs))) bad = [] for p in sample_imgs: img = cv2.imread(p) if img is None: bad.append((p, "decode_fail")) elif img.mean() < 5: bad.append((p, "too_dark")) return badimg.mean() < 5这个阈值是经验值,用来抓全黑帧。正常画面即使很暗,均值一般也在 10 以上。这个检查不用全量跑,抽样几百张就够发现系统性问题了。
5.3 目录固化与清单同步
所有校验通过之后,最后一步是把目录结构和清单文件固化下来。我习惯在数据集根目录放一个README或者meta.json,记录这次抽帧用的参数(interval、max_frames、quality)、划分比例、随机种子、总视频数、总帧数。这些东西当时觉得多余,过两个月回头复现实验的时候就知道有多值钱。
清单文件也要和实际帧数据对齐。如果抽帧过程中剔除了失败视频,清单里对应的行要删掉,或者单独记到一个excluded.txt里。我倾向于后者,保留剔除记录,方便追溯。
注意:固化之后如果要改抽帧参数重跑,建议输出到新目录而不是覆盖原目录。覆盖的话一旦新参数有问题,旧数据也没了,回滚成本很高。
6. 几个实战中反复出现的问题
6.1 帧顺序错乱:补零位数不够的锅
前面提过文件名补零,这里再强调一次位数。五位补零能撑到 99999 帧,对绝大多数视频够用。但如果你处理的是超长视频或者监控数据,帧数可能上万,五位就不够了。判断标准很简单:补零位数要大于等于该数据集单视频最大帧数的位数。HMDB51 用五位绰绰有余,但换成别的数据集前最好先确认一下。
帧顺序错乱的另一个来源是某些系统对文件名排序不区分大小写,或者把数字当字符串排。稳妥的做法是在 Dataset 里读帧时显式按数值排序,而不是依赖文件系统的返回顺序:
frames = sorted(os.listdir(vid_dir), key=lambda x: int(x.split(".")[0]))这一行能挡掉大部分顺序问题。
6.2 存储与 IO:几十万小文件的现实压力
HMDB51 抽完帧大概几十万个 JPEG 文件。这个量级在本地磁盘上问题不大,但如果你放在网络存储或者某些对 inode 敏感的文件系统上,读写会明显变慢。我遇到过训练时数据加载成为瓶颈的情况,最后定位到就是小文件太多、随机读开销大。
缓解办法有几个:一是抽帧时适当降低分辨率,HMDB51 原始分辨率不高,但如果你处理的是高清视频,抽帧时顺手 resize 到模型输入尺寸附近,能省不少空间和 IO;二是训练时用多进程 DataLoader,把 IO 压力分摊开;三是如果条件允许,把帧序列打包成 LMDB 或者 HDF5 这类容器格式,牺牲一点灵活性换读取速度。不过打包这一步建议放在流程稳定之后再做,前期还是用原始图片方便调试。
6.3 划分比例不是死的:按任务调整
70/15/15 是个通用起点,但不是铁律。如果你的数据集很小,验证集和测试集各留 15% 可能只剩几十个样本,指标噪声会很大,这时候可以考虑 80/10/10 甚至用交叉验证代替固定划分。反过来,如果数据量很大,测试集留 5% 都够用,多出来的可以补给训练集。
还有一个容易被忽略的点:验证集和测试集的用途不同。验证集用来调超参、选模型,测试集只在最后评估一次。如果你在测试集上反复调参,那测试集就退化成了验证集,最终指标不再可信。这个纪律在划分阶段就要立好,别等到实验做了一半才发现测试集被"用脏"了。
6.4 抽帧参数和模型输入的匹配
最后说一个衔接问题:抽出来的帧数,最终要能喂进模型。不同模型对输入帧数的要求不一样,有的要求固定帧数(比如 16 帧、32 帧),有的支持变长。如果你的模型要求固定帧数,那抽帧时最好直接按目标帧数来,或者抽完之后在 Dataset 里做二次采样(均匀取 N 帧或者随机取 N 帧)。
我一般倾向于抽帧时多抽一些(比如目标 32 帧就抽 48 帧),然后在 Dataset 里做随机采样。这样训练时有数据增强的效果,验证测试时用均匀采样保证确定性。多抽的那部分帧相当于给采样留了余量,比刚好抽够更灵活。
7. 收尾:一份可以直接复用的检查清单
流程走到这里基本就完整了。我把整个链路里需要确认的点整理成一份清单,每次处理新数据集的时候照着过一遍,能挡掉大部分低级错误:
- 原始视频盘点完成,类别数、样本数、空目录都确认过;
- 划分清单已生成,按类别分层,随机种子固定,三个划分无视频级交集;
- 抽帧参数(interval、max_frames、quality)已根据帧数分布确定,不是拍脑袋定的;
- 抽帧按"划分/类别/视频/帧"四级目录组织,帧文件名补零位数足够;
- 抽帧失败和空目录已记录并处理,清单与实际数据一致;
- 帧数分布统计正常,中位数落在目标区间;
- 图片抽检无解码失败、无异常黑帧;
- 训练/验证/测试的视频目录名交集为 0;
- 抽帧参数、划分比例、随机种子已写入 meta 文件。
这份清单看着琐碎,但每一条背后都是真实踩过的坑。视频数据集处理这件事,技术难度不高,难在细节的严谨性。抽帧和划分这两步做扎实了,后面的模型训练才有可靠的地基;这两步糊弄过去,后面指标再好也可能是假的。我自己现在的习惯是,每处理一个新数据集,先把这份清单过一遍再开始写训练代码,省下来的返工时间远比检查花的时间多。