
简介这是一份基于机器学习的图像行人轨迹搜索完整工程面向监控视频分析、行人重识别方向的Python开发者。借助输入一张目标图像即可自动在大量视频中检索包含该目标的片段并完成标记解决跨摄像头连续轨迹追踪的常见痛点。压缩包共365个文件以283个json配置、36个pyc编译文件、25个py源码为主另有caffemodel及yolov3.cfg模型文件、说明文档和PDF包体约30.72MB。配套TensorFlow-GPU1.6、Keras2.2等运行环境说明模型配置与源码层次分明便于直接复现和二次改造。目前已有145人浏览学习适合有一定机器学习基础、希望快速搭建行人搜索系统的开发者。1. 图像行人轨迹搜索一张目标图换回几十段命中片段监控视频里找一个人传统做法是肉眼对着录像一帧帧翻运气好十分钟运气不好一下午。这套基于机器学习的图像行人轨迹搜索项目把这件事压成一条自动化流程一张目标图进命中片段出。它的核心不是识别“这个人是谁”而是特征比对——先用 YOLOv3 把画面里的行人全检测出来再把每个人的外观特征与目标图像特征算相似度超过阈值判定为命中SSD 人脸模型随后做二次确认压掉穿衣相似的误报。整套流程基于 Python 3.6 TensorFlow-GPU 1.6 Keras 2.2 OpenCV适合监控检索、数据集清洗前的人物定位也是理解“图像检索”类机器学习项目怎么端到端落地的一个完整样例。2. 模型选型与文件构成YOLOv3 检测、SSD 二次确认、打包残留别动2.1 为什么是双模型而不是单模型行人轨迹搜索这个任务拆开看是两步先“找目标在哪”再“确认目标是谁”。YOLOv3 负责第一步它用 COCO 预训练80 个类别里第 0 类是 person拿过来直接就能出人的检测框不用自己收集数据训检测器。监控画面里行人通常中尺度、站立姿态YOLOv3 用 416×416 输入在这个场景下性价比很高而且配合 OpenCV DNN 模块检测部分根本不需要 TensorFlowCPU 就能跑。SSD 在这里不是做行人检测的而是做人脸确认。res10_300x300_ssd_iter_140000.caffemodel 是 OpenCV model zoo 里的人脸检测器输入固定 300×300权重只有 10MB 左右CPU 单帧几毫秒。把它放在 YOLOv3 命中之后对候选框区域再做一次人脸检测有脸且置信度达标才把这次命中记进结果。这么做最直接的收益是把“穿着同款衣服的路人”这类误报压下去——检索场景里目标返场时大概率还是同一张脸。双模型设计也有代价。背对镜头、低头、小脸分辨率低于 50×50时 SSD 会漏检如果逻辑写成“必须检出人脸才算命中”真目标反而被过滤掉。这块权衡在第 5 章展开先记住一个原则SSD 是复核不是门槛。另外这套双模型流程也体现了计算机视觉和机器学习的配合方式——检测沿用 CV 里的目标检测结构特征提取和相似度比对则由机器学习模型完成两边替换点非常清晰。2.2 压缩包里每一份文件是干什么的解包后先别急着跑按下面这张表核对一遍文件和角色。文件角色处理建议res10_300x300_ssd_iter_140000.caffemodelSSD 人脸检测权重OpenCV model zoo 原版直接使用放根目录yolov3.cfgYOLOv3 网络结构定义保留同时准备 yolov3.weightsmain.meta.json / builtins.data.json / typing.data.json打包器运行期残留不用动与算法无关注意 yolov3.cfg 在但 yolov3.weights 不在压缩包里——248MB 的权重文件一般不会塞进 zip需要自己从官方地址下载后放到 cfg 同目录。这是这套资源最常见的“跑不起来”原因第 5 章会专门说。那三个 json 文件是 PyInstaller 之类的打包器打包含入的运行时元数据常见于把 opencv、numpy 冻结进二进制时自动带上跟搜索流程一点关系没有。别因为看着奇怪就删也别指望它们提供什么配置。2.3 环境依赖这套版本组合为什么卡得这么死摘要里写死 Python 3.6.2 TensorFlow-GPU 1.6.0 keras 2.2.0 numpy 1.18.1这个组合本质是 2018 年前后的搭配不是随便选的。TF-GPU 1.6.0 对应 CUDA 9.0 cuDNN 7 编译新一代显卡在 TF 1.6 上通常报 “No kernel image is available for execution on the device”Keras 2.2.0 和 TF 1.6 的兼容性稳定但和 numpy 1.18.1 共存时会刷 DeprecationWarning不影响结果第一次跑容易被吓到。我建议按顺序先验证环境能起来再碰检测和检索。python -c import tensorflow as tf; print(tf.__version__) python -c import keras; print(keras.__version__) python -c import cv2; print(cv2.__version__)期望输出 1.6.0、2.2.0cv2 建议 3.4.2 以上。如果 cv2 是 4.x检测部分有一个 API 差异要改第 5 章会给出兼容写法。scikit-learn 在这套流程里通常用于聚类或最近邻检索加速pillow 负责图像格式读写两个都是辅件版本不敏感。提示如果机器上已经有 CUDA 10 以上的环境不建议硬降驱动来迁就 TF 1.6检测和特征提取两条链路里只有特征提取走 TensorFlow视频量不大时 CPU 推理完全够用。3. 特征提取链路从检测框到一条可比对的向量3.1 基线做法HSV 直方图先跑通再谈深度特征先给一个能立刻跑起来的基线。比较两段目标最简单的是颜色直方图把检测框区域转到 HSV三个通道各取 8 个 bin归一化拉成一条向量用 Bhattacharyya 距离衡量相似度。import cv2 def color_hist_feature(crop, bins(8, 8, 8)): hsv cv2.cvtColor(crop, cv2.COLOR_BGR2HSV) hist cv2.calcHist([hsv], [0, 1, 2], None, bins, [0, 180, 0, 256, 0, 256]) cv2.normalize(hist, hist, norm_typecv2.NORM_L1) return hist.ravel() def bhattacharyya(a, b): a a.reshape(-1, 1).astype(float32) b b.reshape(-1, 1).astype(float32) return cv2.compareHist(a, b, cv2.HISTCMP_BHATTACHARYYA) # 0 最像1 完全不像为什么用 HSV 不用 RGB光照强度变化主要影响 V 分量把亮度分量弱化后纯度和色调在换相机、换白平衡时更稳定这是固定机位监控场景里的常见选择。归一化成 L1 让直方图变成概率分布和目标框大小解耦大框小框算出来的分布有可比性。这个基线的局限也明显两个人穿同色系衣服直方图几乎一样目标走近强光源时颜色整体漂移距离值直接失效。它适合用来验证“检测→裁剪→特征→比对”这条链路通不通真正决定检索精度的是深度特征。3.2 深度特征提取MobileNet 输出 L2 归一化向量资源包本身没有带重识别模型的权重我一般用 Keras 的 MobileNet 预训练权重当特征主干。include_topFalse 去掉分类头poolingavg 把最后一层特征图平均成 1024 维向量ImageNet 预训练权重在这种外观检索任务里已经有足够的区分度。想换 ResNet 或者自研轻量网络只改 build_embedder 的返回值就行。import numpy as np from keras.applications.mobilenet import MobileNet, preprocess_input def build_embedder(): base MobileNet(input_shape(224, 224, 3), include_topFalse, poolingavg, weightsimagenet) return base def extract_feature(model, crop, size(224, 224)): img cv2.cvtColor(crop, cv2.COLOR_BGR2RGB) img cv2.resize(img, size, interpolationcv2.INTER_LINEAR) x preprocess_input(img.astype(float32)) x np.expand_dims(x, axis0) feat model.predict(x, verbose0)[0] return feat / (np.linalg.norm(feat) 1e-12)两个参数容易记错。preprocess_input 对 MobileNet 是缩放到 [-1, 1]不是 VGG 那套减均值用错的话特征分布整体偏移相似度全乱。L2 归一化让向量模长为 1之后点积就等于余弦相似度后面匹配只做一次 np.dot省掉反复算模长加 1e-12 是防止空图产生全零向量导致除零。3.3 相似度计算与阈值初选先看分数分布再定阈值向量都是 L2 归一化的余弦相似度直接退化成点积。def cosine_sim(a, b): return float(np.dot(a, b))阈值不能拍脑袋。我习惯先抽一批正负样本看分数分布再给初值。def score_report(query_feat, gallery_feats, labels): pos [cosine_sim(query_feat, f) for f, lab in zip(gallery_feats, labels) if lab 1] neg [cosine_sim(query_feat, f) for f, lab in zip(gallery_feats, labels) if lab 0] return (min(pos), max(pos)), (min(neg), max(neg))通用规律是同类相似度集中在 0.75~0.95异类集中在 0.3~0.6中间有一段谷阈值应该落在谷里。但谷的位置随场景光照、相机型号移动所以初值给 0.75后面第 6 章用标注数据做标定。如果跑出来正负样本区间直接重合说明特征主干不够用这时候先别调阈值换特征比调参数有用。4. 视频搜索主流程逐帧检测、相似度比对、片段合并4.1 帧采样策略与视频参数读取读视频先拿 fps 和总帧数后面算时间戳要用。cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) total_frames int(cap.get(cv2.CAP_PROP_FRAME_COUNT))监控 30fps 下行人步行每秒位移一两个身位相邻帧几乎完全重叠隔帧检测不会漏目标计算量直接减半。我把采样步长写成参数目标快速经过镜头时才降回 1。FRAME_STEP 2 def iter_sampled_frames(cap, stepFRAME_STEP): idx 0 while True: ret, frame cap.read() if not ret: break if idx % step 0: yield idx, frame idx 1这里丢掉的只是检测粒度不影响片段时间戳连续性因为合并片段时用帧索引除以 fps 还原秒。这其实也是“实时图像检索怎么做预筛”的标准答案——不可能逐帧全量检测采样加候选转出是工程上唯一能落地的路子。4.2 YOLOv3 行人检测与 NMS 后处理用 OpenCV DNN 加载 YOLOv3写一个只返回 person 类检测框的函数。def detect_persons(net, frame, conf_thresh0.5, nms_thresh0.4): h, w frame.shape[:2] blob cv2.dnn.blobFromImage(frame, 1/255.0, (416, 416), swapRBTrue, cropFalse) net.setInput(blob) layer_names net.getLayerNames() out_layers net.getUnconnectedOutLayers() out_names [layer_names[i[0] - 1] if isinstance(i, (list, np.ndarray)) else layer_names[i - 1] for i in out_layers] outs net.forward(out_names) boxes, confs [], [] for out in outs: for det in out: scores det[5:] cls_id int(np.argmax(scores)) if cls_id ! 0: # COCO 里 0 才是 person continue conf float(scores[cls_id]) if conf conf_thresh: continue cx, cy, bw, bh det[0:4] * np.array([w, h, w, h]) boxes.append([int(cx - bw/2), int(cy - bh/2), int(cx bw/2), int(cy bh/2)]) confs.append(conf) keep cv2.dnn.NMSBoxes(boxes, confs, conf_thresh, nms_thresh) if len(keep) 0: return [] keep np.array(keep).flatten() return [boxes[int(i)] for i in keep]blobFromImage 的 1/255.0 对应 YOLOv3 cfg 里的 scale 预处理不能再叠 mean否则输入域不对。416×416 是 cfg 的网络输入尺寸想提速可以降到 320监控这种中尺度行人的精度损失有限。NMS 阈值取 0.4两个贴得很近的行人不会因为框重叠被吃掉一个。out_names 那几行同时兼容 OpenCV 3.4 和 4.x 的返回值差异具体原因放第 5 章。4.3 搜索循环与 SSD 二次确认主循环读采样帧YOLO 出框、裁剪、提特征、算相似度命中后交给 SSD 确认。def search_one_video(video_path, query_feat, yolo_net, face_net, embedder, sim_thresh0.75): cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) hits [] for idx, frame in iter_sampled_frames(cap, FRAME_STEP): boxes detect_persons(yolo_net, frame) for (x1, y1, x2, y2) in boxes: if y2 - y1 20 or x2 - x1 10: continue # 太小目标特征不可靠直接跳过 crop frame[y1:y2, x1:x2] feat extract_feature(embedder, crop) sim cosine_sim(query_feat, feat) if sim sim_thresh: continue if face_confirm(crop, face_net): hits.append((idx, x1, y1, x2, y2, sim)) cap.release() return hits, fps最小框阈值 20×10 是经验值目标低于这个分辨率时特征基本不可信留着只会推高误报还白白喂给 SSD。hits 里存的是帧索引不是时间戳便于后面按任意 fps 回放。整条流程最慢的是 embedder.predict如果视频量大可以先把 YOLO 检测过的帧缓存下来特征提取单独一批跑。人脸确认函数单独拆出来def face_confirm(crop, face_net, face_conf0.6): h, w crop.shape[:2] if h 50 or w 50: return False blob cv2.dnn.blobFromImage(crop, 1.0, (300, 300), (104.0, 177.0, 123.0)) face_net.setInput(blob) dets face_net.forward()[0, 0] for i in range(dets.shape[0]): if dets[i, 2] face_conf: return True return FalseSSD 的 BGR 均值 (104, 177, 123) 来自 caffemodel 训练时的配置不能省输入固定 300×300这是 res10 模型名字里写死的尺寸。注意我这里把 face_confirm 当硬门槛实际大规模跑时建议改成加分项理由见第 5 章。4.4 命中帧合并成片段min_gap 参数的物理意义单个命中帧没有意义要把连续的命中帧合并成时间段。def merge_segments(hits, fps, min_gap12.0): if not hits: return [] segs [] start prev hits[0][0] / fps for rec in hits[1:]: ts rec[0] / fps if ts - prev min_gap: segs.append((start, prev)) start ts prev ts segs.append((start, prev)) return segsmin_gap 的物理意义目标在画面里被短暂遮挡后重新出现间隔通常不超过两三秒12 秒的缺口足以区分“同一次连续出现”和“两次路过”。设小了会把同一次出现切成好几段设大了又可能把两次路过拼成一段。监控场景我从 12 起调室内快速遮挡场景降到 5。拿到时间段后写片段视频def clip_segment(video_path, t0, t1, out_path): cap cv2.VideoCapture(video_path) fps cap.get(cv2.CAP_PROP_FPS) w int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) h int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) writer cv2.VideoWriter(out_path, cv2.VideoWriter_fourcc(*mp4v), fps, (w, h)) cap.set(cv2.CAP_PROP_POS_FRAMES, int(t0 * fps)) n int((t1 - t0) * fps) while n 0: ret, frame cap.read() if not ret: break writer.write(frame) n - 1 writer.release() cap.release()整条主流程涉及的可调参数汇总如下参数初值作用conf_thresh0.5YOLOv3 检测置信度门槛nms_thresh0.4非极大抑制 IoU 阈值sim_thresh0.75特征相似度命中阈值FRAME_STEP2隔帧检测步长min_gap12.0 秒片段合并最大缺口注意writer 不要在循环里反复 open/close句柄累积到一定数量后 mp4v 编码器会直接报错一个片段一个 writer用完就 release。5. 避坑排查五个让搜索失效的典型问题5.1 TensorFlow 1.6.0 import 就崩现象import tensorflow 卡住随后报 libcudnn.so.7: cannot open shared object file或者跑预测时报 “No kernel image is available for execution on the device”。原因TF-GPU 1.6.0 是按 CUDA 9.0 cuDNN 7 编译的机器上装 CUDA 10.x/11.x 直接不兼容新显卡在旧框架里也经常找不到对应 kernel。解决两条路任选。一是装 CUDA 9.0 cuDNN 7.0 严格对齐二是装 tensorflow CPU 版。检测走 OpenCV DNN特征提取才走 Keras视频量不大时 CPU 完全能接受。我后面实际跑批基本都在 CPU 上驱动问题全消。5.2 readNet 加载 YOLOv3 报 “Could not open the file”现象cv2.dnn.readNet(yolov3.cfg, yolov3.weights) 直接抛异常把 cfg 和 weights 参数顺序写反报的也是同一句。原因压缩包里只有 yolov3.cfg 没有 yolov3.weights248MB 权重不会塞进 zip另外 readNet 第一个参数必须是模型权重文件第二个才是结构定义顺序拿反同样报“打不开”。解决先下载官方 yolov3.weights 放到 cfg 同目录再在代码里用 os.path.exists 检查文件存在且大于 100MB避免跑到一半才发现权重是 0 字节。5.3 getUnconnectedOutLayers 在 OpenCV 3.4 和 4.x 下返回值不一样现象同一段检测代码在 OpenCV 4.5 上正常放到 3.4 上报 list index out of range或者反过来。原因getUnconnectedOutLayers() 在 3.4 返回二维数组每个元素是 [index]在 4.x 返回一维数组。用 outLayers[i][0] 或 outLayers[i] 直接索引都会踩版本差异。解决第 4 章代码里的写法先用 getLayerNames再逐项判断是不是 list/ndarray两种版本通吃。项目正文里 opencv-python 没锁版本这行必须写成兼容写法否则换个环境就翻车。5.4 相似度阈值怎么调都不对现象阈值定 0.75一个视频整段全命中误报爆表另一个视频一个都搜不出来而且目标确实在画面里。原因同类/异类相似度分布区间会随相机型号、曝光、白平衡整体移动。固定机位下 0.75 合适换个镜头同类最高分掉到 0.62同一套参数自然失效。误报爆表则是目标穿着朴素和路人共享了大面积颜色和轮廓。解决不要全局定死阈值。每个视频先抽前 200 个检测框和目标图像算相似度看分布谷底在哪把阈值设到谷里更省事的做法是先用 0.6 的宽松阈值收候选再对候选做 SSD 确认和排序把“命中几个片段”交给排序而不是硬卡阈值。5.5 SSD 人脸二次确认把真目标全杀了现象加了 face_confirm 后目标明明在画面里片段却一个都不输出。原因res10_300x300 对侧脸、低头、背对镜头鲁棒性很差目标背对监控走过去是常态这时候根本没有脸。“必须检出人脸才算命中”等于把大部分真实片段过滤掉了。解决把 face_confirm 从硬性门槛改成加分项——有脸就在相似度上加 0.1没脸不扣分或者只用它给候选排序让带脸候选排前面。我第一次做这个项目在这里浪费了两天改成加分逻辑后误报没涨召回率直接回来了。6. 验证与阈值标定先用时间 IoU 算召回率再扫一遍阈值6.1 用时间交并比做片段级验证先手工标 8~10 段“目标确实出现”的片段作为 GT格式是 (start_sec, end_sec)。检索结果片段和 GT 的重叠长度除以并集长度得到时间 IoU大于 0.3 算命中一次。0.3 是借鉴检测任务 mAP 里 IoU 的惯例更严格的任务可以提到 0.5。def temporal_iou(seg_a, seg_b): inter max(0.0, min(seg_a[1], seg_b[1]) - max(seg_a[0], seg_b[0])) union max(seg_a[1], seg_b[1]) - min(seg_a[0], seg_b[0]) return inter / union if union 0 else 0.0 def evaluate(retrieved, gt_set, iou_thresh0.3): hits 0 for gt in gt_set: if any(temporal_iou(gt, s) iou_thresh for s in retrieved): hits 1 precision hits / max(len(retrieved), 1) recall hits / max(len(gt_set), 1) return precision, recall6.2 阈值扫描与参数固化拿这套评估函数扫一遍阈值选 F1 最高的值作为该场景的最终参数。for thresh in [0.60, 0.65, 0.70, 0.75, 0.80, 0.85]: retrieved run_search(thresh) # 内部复用第 4 章主流程 p, r evaluate(retrieved, gt_set) f1 2 * p * r / (p r 1e-12) print(thresh, p, r, f1)扫完把参数写进 config.json和代码解耦下次直接读配置跑批。{ conf_thresh: 0.5, nms_thresh: 0.4, sim_thresh: 0.72, frame_step: 2, min_gap_sec: 12.0 }从那以后我每次接到新的检索任务都先花半小时标几条 GT、做一遍阈值扫描再正式跑全库而不是上来就批量搜索。这套基于机器学习的图像行人轨迹搜索项目检测、特征、检索三段都留有明确的替换点换场景时不用推翻重来只重标阈值。希望帮到你。本文还有配套的精品资源点击获取