ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

基于YOLOv11与YOLOv8的视频隐私打码系统:从目标检测到跟踪遮罩的工程实践

基于YOLOv11与YOLOv8的视频隐私打码系统:从目标检测到跟踪遮罩的工程实践

监控视频、行车记录仪、街头随手拍,这些东西要对外分享的时候,最让人头疼的就是隐私处理。人脸、车牌、路人、店面里的人影,一个没遮住就可能惹麻烦。我之前做过几个版本的视频打码工具,从最早的纯OpenCV固定区域遮挡,到后来用检测模型逐帧打码,最近重构的这个版本总算能拿得出手了:基于YOLOv11 + YOLOv8做双模型目标检测,配合Supervision库完成跟踪和渲染,做成了一条从视频输入到隐私遮罩输出的完整流水线。这篇就是把整个项目的设计思路、踩坑过程和核心代码拆开聊一聊,适合被批量视频隐私处理折磨过、或者想在视频流里落地检测加跟踪的开发者参考。

1. 把“隐私打码”当成感知问题,而不是画框问题

1.1 固定区域打码为什么到现场就翻车

最早我做打码就是拿OpenCV在视频上画矩形,固定区域直接糊掉。听起来简单,实际用起来漏洞百出。

人不是钉在画面里的。行人走出划定区域,车牌随着车辆转弯移到区域外,这些情况固定区域根本防不住。更麻烦的是,同一个画面里可能出现多个隐私对象,手动给每个对象画一堆区域,工作量本身就很大。最典型的翻车现场是:一个教学视频里讲师在黑板前走动,固定区域是屏幕中央,结果他往旁边一走,整张脸清清楚楚露出来。这种工具只能应付“镜头不动、画面也不动”的理想场景,真实素材全是动态的,必须让打码位置跟着目标走。

所以新版本的核心思路转了个大方向:不再“画一块区域遮住”,而是“识别出画面里每一个需要保护的物体,跟随它、覆盖它”。这本质上是个感知问题,和安防里的目标检测、自动驾驶里的行人识别是同一套技术栈。

1.2 智能打码的完整链路:感知、跟踪、遮蔽

整个系统拆开是三件事:感知、跟踪、遮蔽。

  • 感知:用目标检测模型找出每一帧里的隐私目标,比如人脸、人体、车牌。这一步输出的是边界框(bounding box),标记出目标在画面中的位置。
  • 跟踪:连续帧之间需要把同一个目标关联起来,判断上一帧的人和这一帧的人是同一个人。否则每帧都是独立检测,框就会乱跳、闪烁。
  • 遮蔽:拿到稳定的目标位置之后,用高斯模糊、马赛克或者纯色块把对应区域覆盖掉。这一步要考虑边缘、透明度、贴合的视觉效果。

这三件事里最容易在工程上翻车的是中间那一步。目标检测做到位了,跟踪做不好,打码区域一样会一闪一闪的。很多自制的打码脚本只做检测不做跟踪,短片段问题不明显,一处理长视频就露馅。

1.3 双模型输出的融合思路

这个项目里我用了两个模型同时跑,而不是一个模型做完所有事情。最核心的原因是:隐私目标的种类差异太大,人体、人脸、车牌的特征完全不是一回事。单个通用模型虽然在COCO类上有不错的表现,但用来做隐私保护,我更希望有人脸专用的模型精度,又有通用模型的速度。

我的分工方案是:

  • YOLOv11负责“主检”:检测人脸,以及小尺寸目标。v11的backbone和C2PSA模块对细节特征提取更强,对远距离、小尺寸的人脸更敏感。
  • YOLOv8负责“补漏”:检测人体、车辆、车牌等相对较大的目标,速度更快,充当兜底。

两个模型的结果由同一个后处理管线合并,按照类别取并集并去重。比如同一帧里v11检出人脸,v8检出人体,两者坐标有重叠,保留人脸框,人体框作为扩展区域。这样既不会漏掉人脸,又让整个人体也处在隐私保护范围内,视觉上更自然。

2. YOLOv8 和 YOLOv11 搭档:不是性能竞赛,是分工互补

2.1 YOLOv11 到底比 v8 强在哪

YOLOv11是YOLOv8之后的新一代目标检测模型,从模块结构上最明显的变化是:C3k2模块替代了部分C2f结构,SPPF换成了C2PSA,同时在训练策略上加了类似Anchor-Free和动态标签分配的优化。实际跑下来的感知是:同样的输入尺寸下,v11的mAP更高,尤其是小目标召回率明显提升。

但这里要说句实话,v11在小数据集上的提升幅度没有宣传那么大,它最大的优势在训练充分的大模型版本上。如果只是拿预训练权重直接做推理,在视频隐私打码这个场景里,我认为v11和v8的实际差距不会超过几个百分点。所以我的选择不是“谁好就用谁”,而是“它们各自擅长什么”。

2.2 我的分工方案:v11 主攻人脸,v8 负责快速补漏

人脸在监控视频里往往是像素面积很小、模糊、角度复杂的目标,对模型的小目标检测能力要求高,所以主检测器选了YOLOv11s。YOLOv8n则作为辅助检测器,因为它的推理速度快一个档次,专门负责把画面里的车辆、行人、非机动车这些大目标捞出来,再配合分割区域一起打码。

这样搭配下来有一个额外的好处:v8处理超长视频时的丢帧容错更高。比如在低性能机器上跑,我可以让v8每帧都跑、v11每隔一帧跑一次,两路结果通过跟踪ID做对齐,既保证人脸不漏,又让整体处理速度快接近一倍。

当然,双模型也意味着显存或内存占用翻倍。实际测试里,一张消费级显卡同时挂两个模型做推理是完全没有问题的。我的方法是用同一张卡做两路推理,先跑v8再跑v11,避免同时加载带来的显存拥挤。

2.3 模型选择对照表

对比项YOLOv11(主检测器)YOLOv8(辅助检测器)
实际用途人脸、小目标检测人体、车辆、车牌检测
相对优势小目标召回率更高推理速度更快
推荐规格v11s / v11nv8n / v8s
输入尺寸640 / 800640
推理密度关键帧全检,中间帧抽检逐帧全检,保证跟踪稳定
实际作用保证“脸不能漏”保证“范围内全覆盖”

如果你手头的视频素材人脸普遍很大、很清晰,其实单独用一个YOLOv11n就够用了。但街拍、行车记录仪这类场景人脸尺寸不稳定,双模型配合更保险。

3. 基于 Supervision 的逐帧处理流程

3.1 推理与Detections对齐

Supervision这个库最大的价值是把检测模型输出的杂项数据统一成同一个结构,处理起来省很多事。YOLO系列模型输出的results对象包含NMS后的坐标和类别ID,标准做法是转成supervision的Detections对象,后续的跟踪、绘制、裁剪都围绕这个对象做。

import numpy as np import supervision as sv from ultralytics import YOLO model_face = YOLO("yolo11s.pt") # 人脸检测 model_body = YOLO("yolov8n.pt") # 人体/车辆检测 def results_to_detections(results, class_names): # 把YOLO的results转成supervision的Detections xyxy = results[0].boxes.xyxy.cpu().numpy() conf = results[0].boxes.conf.cpu().numpy() cls = results[0].boxes.cls.cpu().numpy().astype(int) # 只保留我们关心的类别,比如person、car、truck mask = [class_names[c] in ["person", "car", "truck"] for c in cls] xyxy = xyxy[mask] conf = conf[mask] cls = cls[mask] return sv.Detections( xyxy=xyxy, confidence=conf, class_id=cls, )

有个细节:YOLO模型默认带数据增强,推理的时候要显式关闭,否则检测框位置会偏移。我在代码里会加上model.overrides["augment"] = False,确保坐标是真实坐标。

3.2 ByteTrack跟踪稳定ID

逐帧检测的结果如果不做跟踪,同一张人脸在一秒24帧里会有24个不同的框坐标,打码区域跟着抖,甚至出现“一会儿遮住了一会儿露出来”的频闪。这里用到了Supervision提供的ByteTrack实现。

tracker = sv.ByteTrack( track_activation_threshold=0.5, minimum_matching_threshold=0.6, lost_track_buffer=30, ) def update_tracked_detections(raw_detections): return tracker.update_with_detections(raw_detections)

ByteTrack的原理说起来不复杂:以检测置信度和位置相似度做匈牙利匹配,把当前帧的框和上一帧已有的track进行关联。lost_track_buffer这个参数值得说一下,它表示目标丢失多少帧之后才认为轨迹结束。隐私打码场景我会调大一点,比如30到50帧,这样目标被遮挡几帧再出现时,打码框不会“灭了又亮”,视觉上稳定很多。

跟踪器输出的每个检测框会带上track_id,后续的平滑和渲染都依赖这个ID。

3.3 区域平滑与膨胀

跟踪ID拿到之后还有一个问题:即使追踪稳定,检测框本身也在因为目标的轻微移动而抖动。解决方式是对每个track_id维护一个历史坐标池,用滑动窗口平均当前帧位置和历史位置。这样可以消除高频抖动,让打码框处于“缓慢跟随”的状态。

from collections import deque class BoxSmoother: def __init__(self, window_size=10, expand_ratio=0.22): self.history = {} self.window_size = window_size self.expand_ratio = expand_ratio def smooth(self, track_id, xyxy): if track_id not in self.history: self.history[track_id] = deque(maxlen=self.window_size) self.history[track_id].append(xyxy) # 对坐标分量分别求滑动平均 arr = np.array(self.history[track_id]) smoothed = arr.mean(axis=0) # 按固定比例外扩,避免人脸转动、轻微位移导致局部裸露 w = smoothed[2] - smoothed[0] h = smoothed[3] - smoothed[1] x1 = smoothed[0] - w * self.expand_ratio y1 = smoothed[1] - h * self.expand_ratio x2 = smoothed[2] + w * self.expand_ratio y2 = smoothed[3] + h * self.expand_ratio return np.array([max(0, x1), max(0, y1), x2, y2])

外扩比例expand_ratio设成0.2到0.25比较合适。我第一版用的是0.1,看起来遮得很紧,结果实测有个素材里人一转头,半个耳朵就露在外面。后来直接改成0.22,基本稳了。

3.4 高斯模糊vs马赛克,怎么选

打码的渲染主要有两种:高斯模糊和马赛克。

高斯模糊的好处是视觉柔和、过渡自然,缺点是在低码率视频里处理后的边缘可能带有原像素信息残留。马赛克比较“硬”,但一旦放大块足够粗,信息恢复难度更高,安全性更好。两种都用OpenCV就能实现。

import cv2 def apply_mask(frame, masks, blur_kernel=(99, 99)): result = frame.copy() for mask in masks: x1, y1, x2, y2 = [int(v) for v in mask] roi = result[y1:y2, x1:x2] if roi.size == 0: continue # 高斯模糊 roi = cv2.GaussianBlur(roi, blur_kernel, 0) # 如果要马赛克,把roi缩小再放大 # small = cv2.resize(roi, (15, 15), interpolation=cv2.INTER_LINEAR) # roi = cv2.resize(small, (x2-x1, y2-y1), interpolation=cv2.INTER_NEAREST) result[y1:y2, x1:x2] = roi return result

模糊核大小我用的比较多的是(99, 99),核太小时人脸轮廓还能隐约看出来,核太大模糊区域边缘会显得发虚。马赛克块数建议控制在10到20之间,太小等于没遮,太大会影响观看体验。

4. 四个实测里避不开的“脏问题”

4.1 框抖得像心电图

第一次用检测模型打码视频,最直观的翻车是遮罩框剧烈跳动。我排查后发现抖动有两个来源:一是检测框本身在相邻帧的坐标差异;二是NMS在多个目标重叠时选的框不稳定。

解决方式在上面已经给出,滑动窗口平滑+外扩。还有一个细节:如果两个模型的检测结果同时覆盖同一个目标,我会优先使用主模型的框,避免两个模型交替命中导致坐标源切换造成抖动。

4.2 目标转身/遮挡导致打码闪烁

人脸侧脸超过90度、或者目标短暂被路灯杆挡住的时候,检测模型偶尔会直接漏检。下游的打码区域就会瞬间消失,画面上目标的脸突然露出来一下。这不是打码代码的bug,是检测模块的固有漏检。

处理思路:跟踪器带lost_track_buffer,目标漏检时不立刻销毁track_id,而是继续使用上一帧的平滑框保持一段时间。所以前置条件是把lost_track_buffer调到合理值。实测30帧缓冲能够覆盖大多数短暂遮挡,画面完全不会闪。

4.3 置信度阈值怎么定

置信度阈值设太高,漏检多;设太低,误检多。隐私打码场景我宁愿误检也不愿漏检。人脸阈值我给到0.25,人体给到0.3。这个值看起来很低,但配合跟踪平滑之后,单帧的噪声会被平均掉,最终效果反倒更干净。

如果你用在生产环节,建议加一个“人工抽检”流程:先跑一遍低阈值版本,抽取视频中间若干帧人工确认误检情况,如果误检大量存在再按目标类别单独调高阈值。

4.4 多人脸重叠时的边界处理

画面里两个人离得很近,检测框重叠的时候,高斯模糊区域的叠加顺序会互相覆盖。我一开始直接按列表顺序渲染,结果A的模糊框把B的脸边缘切掉了一部分,视觉上像脸上贴了个补丁。

解决方案是:先合并重叠区域,再统一打码。用比较粗暴的办法——把相互重叠超过50%的框做并集,扩外接矩形,之后一次性渲染;重叠不严重的情况下按面积从大到小排序,先渲染大的再渲染小的,避免小区域覆盖大半区域。

5. 性能压榨:如何从幻灯片到连续视频

5.1 跳帧/半精度/输入尺寸三板斧

原始版本跑起来大概只有两秒一帧,播放效果就是幻灯片。后来做了三个方向的优化,性能提升非常明显。

model_face = YOLO("yolo11s.pt", task="detect") model_face.to("cuda") model_face.half() # FP16半精度推理

半精度在支持FP16的显卡上能让推理时间直接减半。输入尺寸上,人多的远景画面用800分辨率检测,特写镜头用640就够了。更激进的方案是跳帧检测:每隔一帧或两帧跑一次检测,中间帧用上一帧的检测结果做光流配准微调,或者干脆沿用上一帧的平滑框。在人脸移动不剧烈的场景下,跳2帧的视觉差异几乎看不出来。

5.2 流式读帧与内存释放

视频处理最忌讳一次性把帧全部读进内存。一条10分钟的1080p视频,按RGB存储单帧大概6MB,一分钟1800帧就是10GB级别,内存再大也扛不住。正确的做法是用流式读取。

import cv2 cap = cv2.VideoCapture(input_video) fps = cap.get(cv2.CAP_PROP_FPS) width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) writer = cv2.VideoWriter( output_video, cv2.VideoWriter_fourcc(*"mp4v"), fps, (width, height), ) frame_idx = 0 while cap.isOpened(): ret, frame = cap.read() if not ret: break # 在这里调用检测、跟踪、打码逻辑 annotated = process_single_frame(frame, frame_idx) writer.write(annotated) frame_idx += 1 cap.release() writer.release()

用VideoWriter直接逐帧写入磁盘,内存占用基本恒定在几百兆以内。处理完了注意释放资源,尤其连续批量处理大量视频时,不释放句柄很容易把显存占满导致后续任务跑不起来。

5.3 实测性能表

处理配置分辨率实测速度(帧每秒)
单v8n,FP16,无跟踪640x640约90 FPS
单v11s,FP16,有跟踪640x640约45 FPS
双模型,FP16,完整链路640x640约28 FPS
双模型,FP16,跳帧检测640x640约48 FPS
双模型,CPU推理,完整链路640x640约4 FPS

可以看出CPU推理基本扛不住完整链路,至少需要一张入门级显卡才能谈得上“工具化”。如果只有CPU机器可用,我建议直接把模型降到n规格,并强制跳帧检测,接受流畅度折损。

6. 落地成工具链:批量脚本与FFmpeg配合

6.1 从一个文件到一个目录

做工具和做项目最大的区别就是会不会处理批量。我最终把整条流程封装成了脚本,支持输入一个目录,自动遍历所有视频并生成打码版本。输出文件名保持在原文件名末尾加_privacy后缀,方便对照检查。

python privacy_marker.py --input ./raw_videos --output ./masked_videos \ --face-model yolo11s.pt --body-model yolov8n.pt \ --threshold 0.25 --skip-frame 2 --blur-kernel 99

参数解析建议直接上argparse,别写死在代码里。每次处理不同来源的视频,置信度和跳帧参数大概率都要微调。

6.2 输出编码与FFmpeg处理

OpenCV的mp4v编码器输出的文件体积偏大,且很多播放器兼容性一般。我通常是先让Python侧输出无损或低压缩的中间视频,最后用FFmpeg统一转成H.264格式,画质损失小,体积也友好。

ffmpeg -i masked_output.mp4 -c:v libx264 -crf 20 -preset slower final_result.mp4

需要注意一个坑:OpenCV写入视频的帧率和原始视频必须保持一致,否则会出现音频视频不同步。如果原视频带音轨,还要用FFmpeg把音频流单独抽取再合并回来。这条处理链路建议独立成一个shell脚本,整体流程会更顺滑。

6.3 后续可以扩展的思路

现在的版本只做了检测和跟踪,还没有接入流媒体场景。如果要处理直播流,可以把输入源从VideoCapture换成网络流地址,打码逻辑不变,但需要增加延迟控制。另一个方向是把检测到的隐私区域直接替换成动态模糊版,而不是静态模糊,对画面连续性有更高要求的场合能减少视觉突兀。还有一个方向是融入目标重识别(ReID),在视频跨越不同摄像头时保持同一个目标的打码状态,这套方案对多路监控素材非常有用。

我个人的体会是:这类工具的问题从来不是“模型不够准”,而是“工程细节不够稳”。检测模型给你一个大致位置,真正的价值全在下游的跟踪、平滑、渲染这些看起来不起眼的环节上。如果你也被视频隐私处理折腾过,建议从这篇提到的链路开始搭,先跑通再优化,肯定比从零开始踩坑舒服很多。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进