简介:行为识别实战第二天的完整资料包,将目标检测、动作识别与多目标跟踪三个环节组成一条流水线:先由Yolov5在每一帧中定位对象,再由SlowFast分析时序动态判断动作,最后用DeepSort持续跟踪目标。集成框架面向计算机视觉开发者和需要落地实时行为识别项目的人员,可解决单人或多目标场景下动作分类与身份关联不稳定的问题。资源共180个文件,整体体积约994MB,其中包含64个Python脚本、42个YAML配置、11个Markdown说明、6个Shell脚本,另有模型权重、演示视频及多版本部署文件,能够支撑从环境搭建到模型推断的完整复现。已有963人浏览学习。借助包内资料,可以对照Yolov5检测与SlowFast识别结果,理解DeepSort如何利用外观和运动特征保持目标编号一致,并通过演示片段与说明文档快速排查搭建中的问题,适合直接用于算法验证和二次开发。
1. 行为识别实战第二天:Yolov5+SlowFast+deepsort 这条流水线到底在做什么
标题里的「行为识别实战第二天」,落到系统上其实就是 Yolov5+SlowFast+deepSORT 这条经典三段式流水线。它回答的问题很直接:给定一段视频,找出画面里每一个人是谁、在哪个位置、从什么时间开始做了哪个动作。YOLOv5 负责在单帧里把人 detection 出来,deepSORT 负责把同一个人的检测框跨帧关联成一条轨迹并分配稳定 ID,SlowFast 则负责把「一个人最近 64 帧的图像序列」交给动作分类器,输出「站立、行走、举手、跌倒」这类语义标签。这套方案特别适合不追求毫秒级延迟、但要求动作粒度细的落地场景,比如车间工位合规检测、养老院跌倒识别、门店顾客行为分析。我在实际项目里第一次把它跑通时最大的感受是:三个模型各自都不难,难的是怎么把它们的口径对齐——让人脸框、轨迹 ID、视频片段这三样东西在同一套时间轴上工作,这才是第二天最值得花时间的地方。
2. 动作识别流水线:为什么把检测、跟踪、分类拆成三条线
2.1 分工逻辑:谁在看人、谁在认人、谁在记人
先纠正一个常见误解:行为识别不等于单模型端到端输出「谁在做什么」。绝大多数工程项目的落地形态,都是把动作识别拆成检测、跟踪、分类三个独立环节,各管一段。检测器解决的是空间问题——这一帧哪里有个人,用一个框把它框出来;跟踪器解决的是时间一致性问题——上一帧的 3 号框和这一帧的 3 号框是同一个人,不能因为人动了一下就换 ID;动作模型解决的是语义问题——这个 ID 对应的图像序列,到底属于哪种动作类别。
这个分工的价值在于可以独立更换组件。你今天用 YOLOv5s 觉得检测精度不够,可以换成 YOLOv5m 或者 YOLOv8,其他两段不受影响;你觉得动作类别不够细,只需要重训 SlowFast,不需要动检测和跟踪。如果是端到端的时空动作检测模型,换一个场景几乎等于全部重训,标注成本会翻好几倍。
我在实际项目里一般会把三个模型分别部署成三个服务或者三个线程,中间用带时间戳的队列连接。检测器跑 30fps 是没有意义的,因为 SlowFast 一分类就要吃掉连续 64 帧,跟踪器又在等检测结果,整体吞吐就由最慢的一段决定。初期先把三段各自跑通,再逐步做性能调优,这是我反复验证过的稳妥路线。
2.2 SlowFast 的双帧率设计:快路径和慢路径各看到了什么
SlowFast 是 Facebook AI 提出的视频识别模型,它的名字已经说明了核心思想:一个网络分两路走,一路看得慢,一路看得快,最后融合。慢路径以较低的帧率采样,但通道数更多,负责识别「这是什么物体、什么场景、什么人」,属于空间语义担当;快路径以较高帧率采样,但通道数较少,负责捕捉「物体怎么动的、运动速度有多快」,属于时间动态担当。
这两条路径在结构上共享权重吗?不共享。慢路径的通道数是快路径的 alpha 倍(默认 alpha=8),快路径的帧率是慢路径的 tau 倍(默认 tau=8)。简单理解就是:慢路径看 8 帧里挑 1 帧,快路径 8 帧全看,但快路径的每一层都比慢路径窄。最后通过横向连接把两条路径的特征拼到一起,送入分类头。
用 SlowFast 做行为识别,其实用的不是完整视频,而是「围绕某个人物的时空片段」。训练和推理时取一个人最近 64 帧的检测框裁剪序列,缩放到 256×256 后送入网络。SlowFast 对运动的敏感度是它的强项,但也正因为如此,它对输入片段的连续性和帧间隔非常敏感——喂进去的帧如果是乱的,或者中间丢了几帧,慢路径和快路径的时间对不齐,输出基本就是玄学。
2.3 选型理由:为什么不直接上端到端动作检测模型
可能有人会问:既然有 AVA 这类动作检测数据集和对应的端到端检测模型,为什么还要用三件套拼?原因有两点。第一是标注成本,AVA 类模型需要同时标注空间位置和动作类别,每个动作实例都要画框,一辆 10 分钟的视频标注下来人工成本非常高;而三段式方案里,检测和跟踪都可以用预训练权重,只有 SlowFast 的动作分类部分需要自己的标注,标注的是「视频时间段 + 动作类别」,不需要逐帧画框。第二是训练难度,端到端时空检测模型需要大批量多卡训练,对显存和训练技巧的要求远超普通从业者的环境,而 SlowFast 分类器在单卡上就能训起来。
我还想强调一个工程上的理由:解耦让排查问题变得简单。如果输出结果错了,你可以分别验证——是检测漏了人、跟踪跟丢了 ID,还是动作分类判错了?三个环节各自有日志和可视化输出,你不会面对一个黑匣子无从下手。这套方案当然有它的代价,比如整体速度比单模型慢,但作为实战项目的第一版,稳定性和可调试性要比极限性能重要得多。
3. 搭一个最小可运行系统:YOLOv5 检测框、deepSORT 轨迹、SlowFast 分类串起来
3.1 准备环境与权重,先把检测和跟踪跑通
我的习惯是先跑通最小闭环,再往上加复杂度。这里给一个最小启动脚本骨架,假设你已经准备好视频文件和 Python 环境。
import cv2 import torch from deep_sort_pytracker import DeepSort # 注意:实际库名看你自己 clone 的仓库 # 1) 加载 YOLOv5s 检测器,只保留 person 类(COCO 类别 id = 0) detector = torch.hub.load('ultralytics/yolov5', 'yolov5s', pretrained=True) detector.classes = [0] detector.conf = 0.4 detector.iou = 0.5 # 2) 加载 deepSORT 跟踪器 tracker = DeepSort( 'deepsort/checkpoint/ckpt.torch', max_dist=0.2, max_iou_distance=0.7, max_age=70, n_init=3 ) # 3) 逐帧检测 + 跟踪 cap = cv2.VideoCapture('test.mp4') while cap.isOpened(): ret, frame = cap.read() if not ret: break results = detector(frame) # YOLOv5 推理结果 boxes = results.xyxy[0][:, :4].cpu().numpy() # [x1, y1, x2, y2] confs = results.xyxy[0][:, 4].cpu().numpy() outputs = tracker.update(boxes, confs, frame) # 得到带 ID 的检测结果 for det in outputs: x1, y1, x2, y2, track_id = det cv2.rectangle(frame, (int(x1), int(y1)), (int(x2), int(y2)), (0, 255, 0), 2) cv2.putText(frame, f'ID:{track_id}', (int(x1), int(y1)-5), cv2.FONT_HERSHEY_SIMPLEX, 0.6, (0, 255, 0), 2) cv2.imshow('debug', frame) if cv2.waitKey(1) & 0xFF == ord('q'): break cap.release() cv2.destroyAllWindows()这段代码的逻辑是:每一帧先用 YOLOv5 检测出所有人,再把检测框和置信度交给 deepSORT。deepSORT 内部会结合目标的外观特征和运动信息,把当前帧的检测框和已有的轨迹做匹配,输出带稳定 ID 的框。detector.conf 是检测置信度阈值,设太低会把大量误检框送入跟踪器,导致 ID 频繁切换;设太高又会漏人。deepSORT 的 max_dist 是外观特征余弦距离的阈值,控制同名目标在多大特征差异下还认为是同一个人;max_age 是轨迹丢失后保留的帧数,max_age 太大会让已经离开画面的目标继续占用资源,太小又容易在暂时遮挡后跟丢。
这里有一个容易踩坑的接口差异:不同 deepSORT 仓库的 update 方法签名不一样,有的接收 xyxy,有的接收 xywh,有的是update(bboxes, confs, frame),有的是update(bboxes, confs)。我一般会在接进自己的代码前先打印一下outputs的 shape 确认格式,避免拿到手的数据全对不上。这个细节在第一天的实践里直接浪费了我两个小时。
3.2 为每个 ID 维护时序片段:SlowFast 的输入是 64 帧连续画面
SlowFast 不是拿单张图分类,而是拿一个 clip 分类,通常是 64 帧连续画面。这里的关键设计是:给每个跟踪 ID 维护一个「个人帧缓冲」。每一帧只要检测到这个 ID,就把该 ID 对应的检测框裁剪图推入它的缓冲队列;缓冲满 64 帧,就送进 SlowFast 分类一次。
from collections import deque import numpy as np import torch class PersonBuffer: """每个 tracking ID 一个实例,内部维护最近 64 帧的检测框裁剪图""" def __init__(self, maxlen=64): self.frames = deque(maxlen=maxlen) self.action = None self.action_count = 0 def push(self, crop): self.frames.append(crop) def ready(self): return len(self.frames) == self.frames.maxlen def reset(self): self.frames.clear() self.action = None # 加载 SlowFast 模型,hub 版本内部自带预处理 model = torch.hub.load('facebookresearch/pytorchvideo', 'slowfast_r50', pretrained=True) model = model.eval().to('cuda') def classify_clip(buffer, class_names): frames = list(buffer.frames) # 64 个 256x256x3 的 ndarray clip = np.stack(frames) # [64, 256, 256, 3] clip_tensor = torch.as_tensor(clip).permute(3, 0, 1, 2).unsqueeze(0) # [1, 3, 64, 256, 256] clip_tensor = clip_tensor.float() / 255.0 # 归一化,空间裁剪由 hub 模型内部处理 with torch.no_grad(): logits = model(clip_tensor) # 输出 [1, num_classes] action_id = int(logits.argmax(dim=1).item()) buffer.action = class_names[action_id] buffer.action_count += 1 return buffer.actionSlowFast 的输入张量顺序是 [batch, channel, time, height, width],这一点和很多图像模型不同,第一次写很容易 einsum 搞错。我上面把 np.stack 的结果 permute 成 [3, 0, 1, 2],是因为原始数据排布是 [T, H, W, C],需要把通道维提到最前面。如果你用官方源码仓库而不是 hub 版本,还需要手动完成双路径采样:慢路径按 tau=8 等间隔取帧,快路径取全部帧,然后喂给模型的两个分支。hub 版本把这些细节封装在内部了,训推理结果可以复现,但想深入调参还是建议去读源码,这一点我在第四章会展开。
还有一个容易被忽略的配置:class_names 的顺序必须和训练时一致。如果你用的是预训练权重,它默认的类别是 Kinetics-400 的 400 类,输出索引 0 到 399;如果直接把这个索引打上中文标签,看到的结果会非常离谱。我一般先打印一次 logits 的 top-5 置信度,确认模型对当前片段的认识是什么,再决定是否映射到自己的类别体系。
3.3 主循环整合:检测、跟踪、分类三条线程怎么协作
把前面的段落拼起来,还需要解决一个「什么时候分类」的问题。如果每一帧都对每个 ID 分类一次,SlowFast 的推理耗时会让整个管线卡死。常见做法是降低分类频率:检测和跟踪每帧都跑,但每个 ID 每累计 30 帧再触发一次分类。这样动作标签比真实动作滞后约 1 秒,但监控类场景完全可以接受。
cap = cv2.VideoCapture('test.mp4') buffers = {} frame_idx = 0 CLASSIFY_EVERY = 30 while cap.isOpened(): ret, frame = cap.read() if not ret: break frame_idx += 1 results = detector(frame) boxes = results.xyxy[0][:, :4].detach().cpu().numpy() confs = results.xyxy[0][:, 4].detach().cpu().numpy() outputs = tracker.update(boxes, confs, frame) for det in outputs: x1, y1, x2, y2, track_id = det crop = frame[int(y1):int(y2), int(x1):int(x2)] if crop.size == 0: continue crop = letterbox(crop, target_size=256) # 保持宽高比缩放 + 填充 if track_id not in buffers: buffers[track_id] = PersonBuffer() buffers[track_id].push(crop) if frame_idx % CLASSIFY_EVERY == 0: for track_id, buf in buffers.items(): if buf.ready(): action = classify_clip(buf, class_names) # 在画面上绘制该 ID 的最新动作 draw_action(frame, (x1, y1, x2, y2), action) # 清理长时间不更新的轨迹,避免内存膨胀 active_ids = {int(d[4]) for d in outputs} for track_id in list(buffers.keys()): if track_id not in active_ids and buffers[track_id].action_count > 0: buffers.pop(track_id)这里的 letterbox 函数需要自己实现,核心逻辑是把检测框裁剪图按比例缩放到短边 256,剩余区域用 0 填充,避免直接把长方形 resize 成正方形导致人物形变。帧缓冲满 64 帧后,每次 push 新帧同时丢最老的帧,相当于一个滑动窗口。窗口语义是「这个人最近 64 帧的画面」,所以分类输出反映的是窗口覆盖时间段里的主导动作,而不是瞬时的动作。
清理轨迹的逻辑也很重要。如果目标已经走出画面,它的 ID 还留在 buffers 里,它最后一次的动作会一直显示;要结合 deepSORT 输出的活跃 ID 集合,把已经不活跃的缓冲删掉。我项目里遇到过一个内存稳步上涨的问题,排查到最后就是这些残留 buffer 没清理。
4. 训练自己的行为数据:从标注到喂给 SlowFast 的训练样本
4.1 数据标注格式与自动切片段脚本
很多人第一次接触行为识别,以为要像目标检测一样画框标注。但 SlowFast 是视频分类模型,它对训练样本的要求是「一段视频 + 一个类别标签」。所以在自己的场景里做数据准备,核心工作是把长视频切成动作片段,而不是逐帧标框。标注可以用 DarkLabel 或者 CVAT,标出每段动作的起止时间和类别,导出 JSON 或者 CSV。标注粒度不需要精确到帧,误差 0.3 秒以内都问题不大,因为 SlowFast 会把片段再采样到固定的 64 帧。
拿到标注后,写一个切片脚本把每个标注区间切成固定长度的训练样本。
import cv2 import json import numpy as np with open('annotations.json', 'r') as f: anns = json.load(f) # 每条: {video, start_ms, end_ms, label} for i, ann in enumerate(anns): cap = cv2.VideoCapture(ann['video']) fps = cap.get(cv2.CAP_PROP_FPS) start_frame = int(ann['start_ms'] / 1000.0 * fps) end_frame = int(ann['end_ms'] / 1000.0 * fps) frames = [] cap.set(cv2.CAP_PROP_POS_FRAMES, start_frame) for _ in range(end_frame - start_frame): ret, frame = cap.read() if not ret: break frames.append(frame) cap.release() if len(frames) < 8: continue # 等间隔采样到 64 帧,避免直接丢弃造成动作不连续 idx = np.linspace(0, len(frames) - 1, 64).astype(int) sample = [frames[j] for j in idx] out = f"clips/{ann['label']}_{i:05d}.npy" np.save(out, np.stack(sample)) print('saved', out, sample[0].shape)这段脚本的关键在于等间隔采样。如果原始动作片段有 100 帧,直接从中连续取 64 帧,会丢掉动作后段的信息;等间隔取能让 64 帧覆盖整个动作时间范围。标注边界不准的时候,从 start_ms 和 end_ms 各向内收缩 0.2 秒,可以避免把相邻动作的帧混进来。另外样本的长宽比五花八门,SlowFast 的输入是正方形,训练时在 transform 里做 RandomShortSideScale + CenterCrop,推理时用 letterbox,两种处理方式会产生轻微差异,我在实际对比里发现这个差异有时候会带来两个百分点的精度变化,所以训练阶段最好也模仿推理的 letterbox 方式,或者至少不要混用两种风格。
4.2 SlowFast 必调超参数:alpha、beta、tau 分别影响什么
SlowFast 的默认策略是 slowfast_8x8_r50,也就是慢路径每隔 8 帧取一帧,快路径每隔 1 帧取一帧。它有三个核心超参数,很多人只看默认值,不理解它们对行为识别结果的影响,导致训练效果差不清楚该动哪里。
| 参数 | 默认值 | 调整方向 | 作用与影响 |
|---|---|---|---|
| alpha | 8 | 4~16 | 慢路径通道数与快路径的比值。alpha 越大,慢路径容量越大,更擅长区分外观相似的动作,但参数量上涨 |
| beta | 1/8 | 1/8~1/4 | 快路径通道数的比例系数。beta 太小,运动信息的表达能力不足;beta 太大,训练和推理速度明显变慢 |
| tau | 8 | 4~16 | 慢路径帧采样间隔。tau 越大,慢路径看到的帧越稀疏,适合动作跨度大的场景;动作很快时建议调小 |
| num_frames | 64 | 32~64 | 输入片段总帧数。帧数太少,长时间动作看不全;太多则显存压力大 |
训练时另一个必须关注的是 batch size。SlowFast-R50 输入 256×256,64 帧,单卡 24G 显存下 batch size 大概在 8 左右可以接受;如果显存不够,优先把空间分辨率降到 224,而不是减帧数。学习率用 0.01 配合 cosine 衰减,或用 0.001 配合 step 衰减,两种我都跑过,效果差距不大,真正影响大的是数据质量和类别均衡。
如果你用的是官方 SlowFast 源码仓库,训练配置写在 yaml 文件里,里面有 TRAIN 相关的 num_epochs、batch_size 和 DATA 相关的 train_list 路径。注意官方工具链默认把标注列表当作视频分类格式来读:一行一个视频路径加一个标签索引,并没有显式给时间区间。如果你是自己切的 64 帧样本,每个样本存成单独的视频或者一组帧目录,按这个格式组织就好;如果你想让 SlowFast 直接从长视频里读取随机片段做训练,需要自己写一个 Dataset 类,用我在上面切片脚本里的采样逻辑来返回样本。自己写 Dataset 可控性更强,我一般不走官方的默认读取。
4.3 类别均衡与背景类:行为识别数据里最隐蔽的坑
行为识别数据的类别分布通常极度偏斜。以车间场景为例,「正常作业」占了 85% 的时间,「违规动作」可能只占 3%。如果直接拿这些片段训练,模型会倾向于把所有输入都判成那个大头类别,因为它只需要这么做就能把 loss 降到很低。解决办法有三个,我建议一起做。
第一个是重采样:对样本量少的类别做过采样复制,或者对样本量大的类别做欠采样随机丢弃,让每个类别在一个 epoch 里出现的次数大致一致。第二个是让类别权重进入 loss:PyTorch 的 CrossEntropyLoss 可以直接传 class_weight,按样本数的倒数设置,这是一个很多人忘记的简单手段。第三个是加入背景类:单独收集一批不包含任何目标动作的片段,打上 background 标签。不要小看这个操作,没有背景类的模型,在没有任何动作的静止画面里也会强行输出一个动作类别,这种错误在监控场景里特别伤可信度。
我在最初的项目里没有加背景类,结果是摄像头对着空会议室时模型不断报「举手」。加了背景类并把背景样本占比控制在 15%~20% 后,这个问题基本消失。数据增强也要跟上:每段训练样本做随机裁剪、随机水平翻转(注意别用在有左右语义的动作上,比如「左转」「右转」就不能翻)、随机亮度抖动,这些能在样本量有限的时候明显提升泛化表现。
5. 避坑清单:串联这套系统最容易翻车的 5 个现场
5.1 ID 跳变导致动作标签闪烁
现象:画面里的人没有做任何动作改变,但他头上的动作标签在「站立」和「行走」之间来回跳,或者每过几秒就变一次。 原因:deepSORT 的轨迹 ID 发生了切换。人一旦被遮挡或检测置信度下降,跟踪器就丢了旧 ID,重新初始化一个新 ID。新 ID 的缓冲刚开始只有几帧,积累到 64 帧后分类窗口内容与旧 ID 完全不同,输出自然对不上。 解决:先排查检测器的置信度,不要为了多检测一些人把 conf 压到 0.25 以下;再把 deepSORT 的 max_age 调大一点,比如从 30 调到 70,让短暂遮挡后的目标还能续上旧轨迹;最后在动作输出层加一个「滑动窗口投票」,不要输出每次分类的原始结果,而是取最近 5 次分类结果里的众数,这样即使单个窗口分类错了也会被淹没。
5.2 SlowFast 输入尺寸和长宽比处理不一致
现象:同一个动作,在测试视频的 A 段识别准,在 B 段就错得离谱;而单独拿训练视频里的片段去测,准确率很高。 原因:训练时用的是随机裁剪,推理时如果直接对检测框裁剪图做 resize,破坏了人物的空间比例。监控摄像头角度多样,检测框有的是细长条,有的是近方形,直接把细长条 resize 成 256×256 会让人物横向拉伸,SlowFast 学到的空间特征完全失真。 解决:推理前统一用 letterbox 保持宽高比再送入模型。更彻底的办法是训练时也把样本先 letterbox 再随机裁剪,让训练和推理的分布一致,这个改动比调模型参数更有效。我踩过一次后,把预处理函数单独抽象出来,训练和推理共用一份代码,禁止两套实现。
5.3 时间轴错位:异步管线里帧和轨迹对不上
现象:分类结果和画面里的动作明显有时间差,有时候人已经坐下了,标签还显示「站立」。把分类线程单独跑起来测,速度正常,但整体一慢就乱套。 原因:检测线程、跟踪线程、分类线程各自处理速度不同,没有时间戳约束。分类队列里的 64 帧可能来自三秒前的画面,等它算完,当前画面早就变了。 解决:给每帧打上单调递增的 frame_idx,PersonBuffer 里记录最早和最晚的 frame_idx,进入分类前检查这个时间跨度,如果超过设定的阈值就直接丢弃本次分类请求。主循环里分类的触发频率也不要只看帧计数,因为当检测线程变慢时,帧计数和时间并不严格对应。监控视频的帧率已知,我一般直接把 frame_idx 折算成毫秒时间戳来管理缓存。
5.4 类别不平衡导致模型把一切都判成多数类
现象:训练集里「站立」样本占 70%,测试时发现不管什么画面输出都是「站立」,即使人明显在挥手。 原因:没有做任何类别平衡处理,模型学到了一个简单的捷径,通过预测多数类来最小化损失。 解决:在 4.3 里已经讲过的重采样和背景类是最优先做的。如果做完后仍然偏斜,检查标签是否干净——我遇到过标注视频里混入了相邻动作的帧,导致两个类别的样本在特征空间里大量重叠。清洗一遍标签往往比继续加模型容量更有用。
5.5 推理速度把整个系统拖垮
现象:理想中是 30fps 的实时系统,跑起来实际只有 3fps,画面卡得没法看。 原因:SlowFast 的一个 64 帧 256×256 片段,在消费级 GPU 上单次推理需要 50~200 毫秒,在 CPU 上可能需要 2~3 秒。如果每帧对每个 ID 都分类,计算量根本无法接受。 解决:检测和跟踪保持在 30fps,分类频率降到每秒一次甚至两秒一次;多个 ID 的待分类请求合并成一个 batch,一次喂给模型,而不是循环调用。如果拍摄的是固定摄像头视角,还可以做背景差分,画面没有显著变化时直接跳过分类,这个 trick 能把计算量降一个数量级。
6. 让它能交付:从「能跑通」到「敢上线」的验证和加速技巧
6.1 验证方法:不要只看准确率,要看时序一致性
行为识别模型最容易出现的假象是:单帧准确率很高,但标签在时间轴上跳来跳去,根本没法看。我建议在项目里维护一个「结果视频」的可视化验证流程,把每个 ID 的轨迹框、ID 号、动作标签、当前分类置信度全部画在输出画面上,用肉眼扫一遍五分钟的视频,就能发现准确率指标掩盖掉的一半问题。
指标上,除了每类动作的准确率和召回率,还要计算标签的时间连续性。一个简单方法:统计一段时间内同一个 ID 的动作标签切换次数,如果切换频率远超真实行为变化频率,就说明分类不稳定。更严格的评估是分段编辑距离——把模型输出动作序列和标注的时序分段对齐,计算需要多少次插入、删除、替换才能匹配,这个指标比逐帧准确率更能反映实际体验。
6.2 推理提速:量化、批处理与动态频率控制
部署环节常用三种加速手段,优先级从高到低排列。第一是 batch 化,把多个 ID 的待分类片段拼成一个 batch,减少模型调用次数,这在多人监控场景里收益最明显。第二是 TensorRT 或 ONNX 导出,SlowFast 的算子大部分能直接被 TensorRT 优化,一次导出通常能带来 1.5~2 倍提速,且精度损失可以控制在 1% 以内。第三是动态控制分类频率,画面里没有目标运动时降为每 5 秒分类一次,有人进入画面立即提升频率。
量化是进阶选项。把 SlowFast 的快路径做 INT8 量化,慢路径保留 FP16,通常能在精度几乎不降的情况下再压一截延迟。我踩过一个坑:只量化快路径,不量化慢路径,模型加载后输出全变成了 NaN,排查很久才发现是某些层的量化 scale 设置过大。后来学到的经验是,量化前先跑一个小的校准集,确保每一层输出的数值范围合理。如果你最终要部署到 RK3568 这类边缘设备,建议提前把模型导出成 ONNX 再转 RKNN,直接拿 PyTorch 权重很难绕过后处理算子不支持的问题,这个环节至少会占掉整体移植时间的四成。
最后说一个我自己印象深的教训:第一次把整套系统接到真实摄像头时,我以为模型是准的,结果是视频流的色彩空间没有从 BGR 转成 RGB,SlowFast 看到的画面所有颜色都偏蓝,动作识别全乱套。后来我在管线的入口统一做颜色空间转换,并且把转换前后的画面各保存了一帧做对比,才真正定位到问题。这个教训让我养成了一个习惯:每接入一个新摄像头或新视频源,先跑一段最简单的画面统计脚本,确认帧率、分辨率和颜色空间,再让整条流水线接管。希望这个习惯也能帮到你,省下一些本来不必要的排查时间。
本文还有配套的精品资源,点击获取