ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

行人检测与重识别:从监控视频中快速搜索目标轨迹的工程实践

行人检测与重识别:从监控视频中快速搜索目标轨迹的工程实践 简介面向机器学习与计算机视觉方向的开发者这份压缩包提供了一套基于Python的监控视频行人轨迹搜索方案。通过输入一张目标图像即可在大量视频片段中检索并标记包含该目标的内容适用于安防监控、智慧安防等需要跨镜头追踪的工程场景。资源共365个文件整体约30.72MB。除了25个Python源码与36个pyc编译文件还包含283个JSON配置用于模型参数与运行设置、YOLOv3/SSD等检测模型权重caffemodel、pb、cfg以及txt说明、readme/PDF文档等大量配置文件便于理解不同模型的组织方式与调用逻辑。已有144人学习下载适合作为行人检测与轨迹搜索项目的参考实现。资源提供了可直接落地的工程代码和模型参数随附Python 3.6.2、TensorFlow-GPU 1.6.0、Keras 2.2.0等依赖版本读者可按清单复现实验环境从目标检测、特征提取到轨迹搜索均可在本地跑通。同时整理出的说明文档与注释对排查环境兼容性问题也有参考价值适合拥有Python与深度学习基础、希望快速搭建行人检索原型的开发者。1. 从一张照片到整段轨迹这个项目到底解决了什么问题假设你手上有一堆监控视频想查明某个穿红色外套的人今天在园区里走过了哪些摄像头。人工回放的时候一个小时的视频就要盯一个小时还容易漏看。这个项目做的事情就是输入一张目标图像基于机器学习自动从这些视频里找到包含目标的画面片段并且在每一帧上标记出目标位置最后串联成一条可回溯的轨迹。它不借助 GPS也不依赖任何穿戴设备纯粹靠图像内容理解。项目运行在 Python 3.6.2 TensorFlow-GPU 1.6.0 Keras 2.2.0 的环境上用到了 SSD 和 YOLOv3 做行人检测用 Keras 预训练网络提取身份特征。适合想快速搭建“以图搜人”原型的工程师也适合复现目标检测和行人重识别组合链路的技术人员。2. 行人检测模块SSD 与 YOLOv3 的选型和加载2.1 为什么先做检测而不是全图匹配如果直接拿整帧图像去和目标照片做特征比对背景的干扰会彻底淹没行人信息。监控场景下行人占比通常不到十分之一大量的墙壁、地面、车辆会参与特征计算导致相似度区分度极低。所以在重识别流程里第一步一定是用目标检测把所有候选人物框出来再对每个局部小图做身份特征提取。这一步的质量直接决定最终轨迹搜索的准确率。SSD 和 YOLO 都是经典的一阶段检测器两者的共同点是速度快、框架成熟。项目给出的文件里res10_300x300_ssd_iter_140000.caffemodel是 Caffe 训练出的 SSD-ResNet10 模型而yolov3.cfg是 Darknet 的 YOLOv3 配置文件缺一个yolov3.weights权重文件。你需要根据手头模型的情况补齐权重文件否则后续加载会直接报错。2.2 OpenCV 加载 SSD 模型与 YOLOv3 配置OpenCV 的 DNN 模块同时支持 Caffe 和 Darknet 两种格式所以不需要安装额外的推理引擎cv2.dnn就能搞定。先看 SSD 的加载代码import cv2 # 加载 Caffe 格式的 SSD 模型 # deploy.prototxt 是网络结构可以从 OpenCV 示例目录获取 ssd_net cv2.dnn.readNetFromCaffe( deploy.prototxt, res10_300x300_ssd_iter_140000.caffemodel ) # 加载 YOLOv3 模型 # 注意只有 cfg 文件无法加载必须补充 weights 文件 yolo_net cv2.dnn.readNetFromDarknet( yolov3.cfg, yolov3.weights )这段代码有两个关键点第一个readNetFromCaffe第一个参数是 prototxt 结构文件项目里没有列出一般放在 OpenCV 的samples/dnn目录下Copy 到工作目录即可。第二个readNetFromDarknet必须成对提供 cfg 和 weights如果你只拿到了 cfg需要先通过官方权重补齐要么就用cv2.dnn.readNet直接读 ONNX 版本。我一般会在启动脚本里加一个文件存在性判断避免权重路径写错了让服务崩在中间。接着把帧送入模型# SSD 预处理BGR 均值是 (104.0, 177.0, 123.0) blob cv2.dnn.blobFromImage( frame, 1.0, (300, 300), (104.0, 177.0, 123.0), swapRBFalse, cropFalse ) ssd_net.setInput(blob) detections ssd_net.forward()blobFromImage将原始 BGR 图像转换成一个 4 维 blob 作为网络输入。scale factor这里设 1.0如果是 YOLO 需要换成1/255.0做归一化。size是网络要求的输入分辨率SSD 是 300x300。mean必须用训练时设定的 BGR 均值错了会严重影响检测置信度。swapRBFalse是因为 Caffe 模型本身就是 BGR 通道顺序不需要交换。YOLOv3 的输入输出要复杂一点因为它的检测头有三个尺度需要拿到未连接输出层的名字ln yolo_net.getUnconnectedOutLayersNames() blob cv2.dnn.blobFromImage( frame, 1/255.0, (416, 416), (0, 0, 0), swapRBTrue, cropFalse ) yolo_net.setInput(blob) outs yolo_net.forward(ln)这里getUnconnectedOutLayersNames()返回的是 YOLOv3 三个 YOLO 输出层的名称forward(ln)会返回三个张量的列表每个张量包含检测框坐标、目标得分和类别得分。注意它的坐标格式是中心点加宽高并且是归一化到 0~1 的值后续要转换成图像像素坐标。2.3 检测结果的后处理与参数调整下面这个表格可以帮助你做选型判断对比项SSD (ResNet-10)YOLOv3输入尺寸300x300416x416推理速度快中等行人检测精度一般较高配置复杂度需 prototxt caffemodel需 cfg weights输出张量单层多层需 NMSSSD 输出结构是[1, 1, N, 7]每个检测结果包含[image_id, class_id, score, x1, y1, x2, y2]这里的坐标是归一化后的需要乘图像宽高。我一般会把置信度阈值设在 0.5太低了会出现大量误检太高会漏掉背身、暗光下的行人。NMS 的阈值 0.4 适合行人这种重叠度较高的目标。用一个函数把检测结果转为标准坐标def parse_ssd_detections(detections, frame_shape, conf_thresh0.5): h, w frame_shape[:2] boxes [] for det in detections[0, 0]: score float(det[2]) if score conf_thresh: continue x1 int(det[3] * w) y1 int(det[4] * h) x2 int(det[5] * w) y2 int(det[6] * h) # 边界裁剪避免越界 x1, y1 max(0, x1), max(0, y1) x2, y2 min(w, x2), min(h, y2) if x2 - x1 0 or y2 - y1 0: continue boxes.append((x1, y1, x2, y2, score)) return boxesconf_thresh0.5是默认值实际调试时可以用一个小的可视化工具观察同一个模型在不同阈值下的效果。裁剪后的小图就是下一步特征提取的输入。3. 行人特征提取用 Keras 把“人”变成向量3.1 重识别任务对特征的要求行人重识别需要解决的核心问题是同一人的表观特征在不同摄像头、不同光照、不同姿态下保持稳定。监控视频里行人的分辨率往往很低还可能被遮挡。直接使用原始像素无法适应这些变化所以必须通过深度卷积网络提取高维语义特征。一种被广泛使用的快速方案是使用 ImageNet 预训练的分类网络去掉最后的分类层用倒数第二层或全局池化层输出作为特征向量。这样做的好处是无需训练利用迁移学习能力特征对常见物体已有较好的判别性。项目环境里的 Keras 2.2.0 内置了多个经典网络开箱即用。对于一个快速原型来说MobileNet 是平衡速度与精度的首选。3.2 基于 Keras 的特征提取器实现直接给出代码from keras.applications import MobileNet from keras.applications.mobilenet import preprocess_input from keras.models import Model from keras.preprocessing.image import img_to_array import numpy as np base_model MobileNet( weightsimagenet, include_topFalse, input_shape(224, 224, 3), poolingavg ) feature_extractor Model( inputsbase_model.input, outputsbase_model.output ) def extract_feature(person_crop): img cv2.resize(person_crop, (224, 224)) img img_to_array(img) img np.expand_dims(img, axis0) img preprocess_input(img) feat feature_extractor.predict(img)[0] norm np.linalg.norm(feat) return feat / norm这个函数完成了从裁剪图到归一化特征向量的转换。include_topFalse表示去掉最上层的 1000 类分类器poolingavg表示用全局平均池化将最后一层特征图变成 1024 维向量。preprocess_input在这里执行的是“RGB 转 BGR、减均值、除方差”一套操作虽然函数名叫mobilenet.preprocess_input但它已经内置了对应网络的预处理规则。最后用 L2 范数归一化让特征向量处于单位超球面上这样直接内积就能得到余弦相似度。不同网络在相同任务上的特征维度差异很大选择时要考虑显存和计算速度特征网络输出维度单张 224x224 推理耗时V100 参考特点MobileNet1024约 5ms速度快适合视频流VGG16512约 25ms特征稠密但计算量大ResNet502048约 12ms精度和速度折中实际项目里我通常先在 CPU 上用 MobileNet 跑通流程等验证没有问题后再换成 ResNet50 提升精度。换网络时只需要改动MobileNet这个类名和input_shape其余代码不受影响。3.3 相似度计算与阈值设定特征向量之间的距离度量一般用余弦相似度或欧氏距离。因为我们已经做了 L2 归一化两者可以互相转换。下面这个函数就是最终匹配的判断逻辑def is_match(query_feat, cand_feat, thr0.75): return float(np.dot(query_feat, cand_feat)) thrnp.dot计算的是内积由于两个向量都已经是单位向量内积就是余弦相似度。阈值thr0.75并不是一个绝对经验值它会随着摄像头视角、目标分辨率、特征网络而变化。我个人的调法是把目标出现过的帧提取特征和所有检测特征做一次全量相似度统计画一个直方图选择波谷位置为阈值。如果你不想画图那么先用 0.75 起步是合理的。注意这里query_feat可以从一张目标照片提取也可以从三到五张照片分别提取后求平均向量后者对姿态变化的鲁棒性会好很多。如果监控里目标戴着口罩、墨镜导致特征区分度下降就要考虑引入时序约束比如匹配帧是否连续出现这就是下一章的内容。4. 视频中的轨迹搜索从逐帧匹配到片段输出4.1 视频处理主循环的设计搜索逻辑本质上是把检测、特征提取、相似度匹配三个步骤串起来对每个视频逐帧执行。我按如下方式组织主循环cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) frame_count 0 hit_frames [] hit_boxes [] while True: ret, frame cap.read() if not ret: break frame_count 1 # 跳帧处理避免对每一帧都做检测 if frame_count % skip_frames ! 0: continue boxes detect_persons(frame) # 返回 (x1,y1,x2,y2,score) crops [crop_person(frame, b) for b in boxes] if not crops: continue # 批量特征提取 batch preprocess_crops(crops) feats feature_extractor.predict(batch, batch_size16) for b, feat in zip(boxes, feats): if is_match(query_feat, feat): hit_frames.append(frame_count) hit_boxes.append(b)skip_frames通常设为 2 或 3因为监控视频相邻帧内容变化很小跳帧会让检测速度提升接近一倍而召回率损失不大。detect_persons内部封装了 SSD 或 YOLO 的预处理和后处理和第二章的parse_ssd_detections对应。preprocess_crops负责把所有裁剪图统一 resize 成 224x224并完成归一化。这样整个搜索过程不需要逐张预测predict(batch_size16)能利用 GPU 并行能力比 for 循环快很多。4.2 轨迹标记与片段保存得到所有命中帧后首先要做的是分片段。因为目标不可能一直出现在镜头里离开后再次出现应视为两个不同片段。我用的合并规则是如果两个命中帧之间的间隔小于max_gap则视为连续否则切开。代码实现如下def merge_frames(hit_frames, max_gap10): groups [] if not hit_frames: return groups start prev hit_frames[0] for fid in hit_frames[1:]: if fid - prev max_gap: groups.append((start, prev)) start fid prev fid groups.append((start, prev)) return groupsmax_gap10的含义是允许目标短暂被遮挡 10 帧也就是约 0.33 秒。这个值不适合设太大否则两个独立出现事件会被错误地连成一个长轨迹。合并后每个(start, prev)就是一条轨迹的起止帧号可以用它们从原视频中裁剪出命中片段。这里我建议按原始帧率截取片段而不是用命中帧的离散列表这样输出视频连续可看。同时对每帧画上这次的包围框out cv2.VideoWriter( fresult_{segment_id}.avi, cv2.VideoWriter_fourcc(*XVID), fps, (width, height) )然后回放这个片段内的每一帧把之前保存的hit_boxes画上去。注意画框的坐标要用检测时归一化转换后的整数坐标否则会出现偏移。片段文件名带上段序号方便事后核对。4.3 搜索效率优化跳帧与批量特征比对如果只是做几个文件的实验逐帧跑也能接受。但面对“大量视频”这个原始需求效率优化是第一优先级。除了上一节提到的批量预测还有两个常用做法帧差法跳过静止画面以及降低检测模型输入分辨率。帧差法的思路是计算当前帧和前一次处理帧的平均像素差差小于阈值就认为画面没有明显变化直接跳过检测不过行人移动缓慢时帧差也很小所以skip_frames机制更稳妥。我给出的默认参数如下表你可以直接用于第一轮实验参数推荐值作用conf_thresh0.5检测框置信度阈值nms_threshold0.4抑制重叠框skip_frames2每 N 帧处理一次max_gap10轨迹片段合并最大间隔match_thr0.75特征相似度阈值表中的数值之间有关联。如果skip_frames提高目标运动导致的框位置变化更大max_gap也要适当放宽如果match_thr提高漏检会增多但误检变少。这个表格是我建议的起点你在自己数据上要重新标定。5. 工程化落地版本兼容、排错和验证方法5.1 环境版本冲突的坑这套环境最“老”的依赖是 TensorFlow-GPU 1.6.0它对 numpy 的兼容性很挑。项目里写的是 numpy 1.18.1但这个版本里很多 API 行为已经变化经常导致 Keras 的preprocess_input报“Numpy array is not sliceable”之类的异常。我建议先执行一次把 numpy 降到 1.16.6然后再观察是否还有兼容问题。如果连 TensorFlow 都懒得装也可以考虑把特征提取换成纯 OpenCV DNN 加载 ONNX 模型这样能绕过整个 TensorFlow 依赖链。protobuf 也是一个容易忽略的坑。OpenCV DNN 读取 Caffe 模型需要 protobuf而 TensorFlow 1.6 依赖 protobuf 3.5两者同时压在环境里会互相干扰。经验做法是单独建一个 Conda 环境把 protobuf 固定为 3.6.1这样两边都能跑通。5.2 怎么验证搜索结果是否正确功能跑通不等于结果可信。我会从两个层面验证。第一个层面是准确率抽查把程序认为“命中”的帧都抽出来拼成一张大图用人工快速判断这些框里是否确实有目标。第二个层面是定量验证准备一个 5 分钟的测试视频人工记录目标真实出现的区间和程序输出的命中区间对比计算 IoU 或者覆盖率。这样你才能确定match_thr调到 0.75 还是 0.70。现实场景还有一类典型问题目标穿着黑色衣服在暗光下特征区分度不够导致某个无关路人频繁被命中。这种情况不是调阈值能解决的我一般会在匹配策略上增加“连续命中”约束比如要求至少连续 3 个命中帧才认为是有效轨迹从而过滤掉单帧孤立的误检。5.3 一个快速冒烟测试脚本冒烟测试的目标是验证整条链路通畅。我会准备一个 30 秒的短视频里面确保目标只出现了一次然后运行下面的脚本import cv2, json, glob query_img cv2.imread(query.jpg) query_feat extract_feature(query_img) for video_path in glob.glob(test_videos/*.mp4): result search_video(video_path, query_feat) json_path video_path .json with open(json_path, w) as f: json.dump(result, f, indent2, ensure_asciiFalse) print(video_path, 命中片段数:, len(result[segments]))search_video就是第四章主循环的封装返回包含segments、frames和boxes的字典写入 JSON 方便后续分析。如果脚本输出一个命中片段且位置正确再跑全量视频集。如果没有任何片段命中先降低match_thr或者换一张更清晰的 query 图如果命中片段过多则提高阈值并检查检测阶段是否有大量误检框进入了特征提取。最后建议把所有运行参数集中放在一个config.py里不要散落在脚本各处。这样你在复现实验时只需要改参数表里的几个值不用改动任何算法逻辑。本文还有配套的精品资源点击获取
返回列表