简介:这份资源面向有一定Python和深度学习基础的开发者,可用于智慧安防、行人重识别等课题研究。其核心功能是输入一张目标人物图像,即可在监控视频库中自动搜索出现该目标的片段并标注位置,极大提升视频检索效率。压缩包包含365个文件,约30.72MB,其中包含283个json数据文件、25个Python源码文件、36个pyc编译文件,以及caffemodel、pb等预训练模型和网络配置文件,覆盖模型加载、目标检测、轨迹匹配到结果输出的完整链路。附带的运行环境说明与使用文档有助于快速本地复现,读者可借此熟悉从视频帧预处理、目标检测框提取到跨帧轨迹关联的完整流程,并可直接替换自有数据集进行迁移训练。项目整体结构清晰,已有144人学习,适合作为计算机视觉项目实战参考。
1. 行人轨迹搜索:一张目标图,从半小时监控里把人捞出来
行人轨迹搜索这类任务,第一反应通常是“做人脸识别”,但实际能落地的方案往往不是这样。这个项目抛出一个很朴素却很有效的思路:先检测、再跟踪、后比对。你手里只有一张目标的图像,系统会从一段甚至多段视频里自动定位所有出现该目标的片段,并把目标框标出来,形成一个可回放的轨迹。
它解决的是监控场景里最费人力的活儿——靠人眼在一小时视频里找一个人。适合两类人:一类是做监控视频分析的算法工程师,想找一个能直接跑的检索基线;另一类是刚入门机器学习、想搞清楚“检测、跟踪、检索”怎么串成一条完整流水线的学生。整个系统的技术栈是 Python 3.6 + TensorFlow-GPU 1.6 + Keras 2.2 + OpenCV,属于两年前的经典组合,但思路放到今天依然不过时——检测换成一个新模型,检索部分基本不用动。
2. 资源里的两个模型文件:检测前端选型与环境自检
2.1 资源结构拆解:caffemodel、yolov3.cfg 各负责什么
打开这个资源包,看到的文件有点“寒酸”:没有源码,只有几个模型文件和配置文件。但仔细拆开,信息量其实不小。
核心是这两个模型文件:
| 文件 | 格式 | 角色 |
|---|---|---|
| res10_300x300_ssd_iter_140000.caffemodel | Caffe 权重 | SSD 人脸/行人检测器,输入 300x300,OpenCV DNN 可直接加载 |
| yolov3.cfg | DarkNet 配置 | YOLOv3 网络结构定义,需要配套的 .weights 权重文件才能推理 |
这个组合说明一件事:作者在检测前端上做了两手准备。SSD 模型轻量、在 CPU 上也能跑得动,适合做第一遍全视频扫描;YOLOv3 结构灵活,如果当时环境里只有 DarkNet 或者想追求更高的检测召回率,可以随时切换。实际开发中一个常见做法是先用 SSD 扫描,漏检高的场景再换 YOLOv3 对比结果——两份模型互为备份。
另外包里还混着 main.meta.json、builtins.data.json 这类文件,看起来像是某个 IDE 或者序列化工具留下的缓存,和核心逻辑无关,可以直接忽略,不用在它们上面浪费时间。
2.2 环境自检:三行代码确认引擎能用
这个项目对版本匹配极其敏感,特别是 TensorFlow 和 Keras 的搭配。先确认运行环境:
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.0 和任意 opencv-python 版本。这里有个实际坑:TensorFlow 1.6 对 Python 版本有硬性要求,Python 3.6.2 是安全的,如果换到 Python 3.7,import tensorflow 会直接报错。keras 2.2.0 与 TF 1.6 配套使用问题不大,但如果你贪新装了 keras 2.3 以上,就会碰到后面避坑章节里说的兼容性翻车。
确认环境能跑之后,再验证 OpenCV 的 DNN 模块能不能加载那个 caffemodel:
import cv2 net = cv2.dnn.readNetFromCaffe("deploy.prototxt", "res10_300x300_ssd_iter_140000.caffemodel") print("load ok, layer count:", net.getLayerCount())这里注意一个细节:readNetFromCaffe需要两个参数,第一个是网络结构的 prototxt 文件,第二个才是权重。如果资源包里只有 caffemodel,没有 deploy.prototxt,那需要自己补一个。OpenCV 官方仓库里有对应这个模型的标准 prototxt,搜索deploy.prototxt res10_300x300_ssd_iter_140000就能找到,把文件放到同目录下再执行上面的代码。
2.3 SSD 与 YOLOv3:这个场景下为什么两种都得留
很多人会纠结:既然有 YOLOv3,为什么还要保留一个 SSD?我的理解是,这两个模型在行人轨迹检索里承担的任务稍有不同。
SSD 的优势在于快和稳定。300x300 的输入,在 GTX 1080 上单帧推理大概 3-5 毫秒,一小时视频约十万帧,跑一遍全视频扫描只要几分钟。而且这个 res10 模型是 OpenCV 官方长期维护的模型,跨版本兼容性好,不会出现 DarkNet 编译链断裂的问题。用它做全量扫描,命中率够用。
YOLOv3 则适合作为验证模型。它的 cfg 文件定义了完整的网络结构,如果你需要更高精度的检测结果,可以补上 yolov3.weights 权重文件,用 OpenCV 或 DarkNet 跑一遍对比。我在实际项目里一般会用两份模型分别出轨迹,取并集——SSD 负责召回、YOLOv3 负责校验,两者都认为存在的轨迹置信度才够高。
所以资源里同时出现这两个模型,不是文件堆砌,而是一条主检测链路加一条备用校验链路的典型设计。把这个逻辑理清楚,后面的轨迹生成和检索排序才有根基。
3. 从视频到轨迹:SSD 检测 + 朴素在线跟踪的可行实现
3.1 逐帧检测:把视频帧变成带坐标的人形框
拿到视频后第一步,是把连续帧里的行人目标全部检测出来,形成带时间戳的检测框序列。这里用 OpenCV 的 DNN 模块加载 SSD 模型,核心脚本如下:
import cv2 import numpy as np net = cv2.dnn.readNetFromCaffe("deploy.prototxt", "res10_300x300_ssd_iter_140000.caffemodel") cap = cv2.VideoCapture("monitor_video.mp4") width = int(cap.get(cv2.CAP_PROP_FRAME_WIDTH)) height = int(cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) fps = cap.get(cv2.CAP_PROP_FPS) conf_thresh = 0.6 results = [] # 每条记录: (frame_id, x1, y1, x2, y2, conf) frame_id = 0 while True: ret, frame = cap.read() if not ret: break # 缩放 + 去均值,SSD 的标准预处理 blob = cv2.dnn.blobFromImage(frame, 1.0, (300, 300), (104.0, 177.0, 123.0)) net.setInput(blob) detections = net.forward() for i in range(detections.shape[2]): conf = float(detections[0, 0, i, 2]) if conf < conf_thresh: continue box = detections[0, 0, i, 3:7] * np.array([width, height, width, height]) x1, y1, x2, y2 = box.astype("int") results.append((frame_id, x1, y1, x2, y2, conf)) frame_id += 1逻辑说明:每个视频帧被缩放到 300x300 并减去均值(104, 177, 123),这是 SSD 模型训练时的预处理标准,不能省略,否则检测框位置会整体偏移。detections[0, 0, i, 2]是第 i 个候选框的置信度,3:7是归一化坐标,要乘回原始视频的宽高。
参数说明:conf_thresh建议在 0.5 到 0.7 之间调。太高会漏掉侧面或被遮挡的行人,太低会产生大量误检,导致后面轨迹关联时串号。我一般先用 0.6 跑一遍,看误检数量再微调。
3.2 轨迹关联:用 IoU 和丢失窗口串起目标
检测框只是散点,要变成“轨迹”,还需要把相邻帧中属于同一个人的框关联起来。这里用一个不引入卡尔曼滤波的朴素在线跟踪,靠 IoU 匹配足够应付大多数场景:
from collections import deque class Track: def __init__(self, track_id, frame_id, box): self.id = track_id self.boxes = deque(maxlen=200) # 最多保留200帧 self.boxes.append((frame_id, box)) self.lost = 0 # 连续丢失帧数 self.active = True def iou(a, b): x1 = max(a[0], b[0]); y1 = max(a[1], b[1]) x2 = min(a[2], b[2]); y2 = min(a[3], b[3]) inter = max(0, x2 - x1) * max(0, y2 - y1) area_a = (a[2]-a[0]) * (a[3]-a[1]) area_b = (b[2]-b[0]) * (b[3]-b[1]) return inter / (area_a + area_b - inter + 1e-6) tracks = [] next_id = 0 iou_thresh = 0.4 max_lost = 15 for det in results: frame_id, x1, y1, x2, y2, conf = det new_box = (x1, y1, x2, y2) best_track = None best_iou = 0.0 for t in tracks: if not t.active: continue last_frame, last_box = t.boxes[-1] if frame_id - last_frame > max_lost: t.active = False continue iou_val = iou(last_box, new_box) if iou_val > best_iou: best_iou = iou_val best_track = t if best_track and best_iou >= iou_thresh: best_track.boxes.append((frame_id, new_box)) best_track.lost = 0 else: tracks.append(Track(next_id, frame_id, new_box)) next_id += 1逻辑说明:每条 Track 保留最近的检测框,新检测框到来时,和所有活跃轨迹的最后一帧框算 IoU,取最大者。如果最大 IoU 超过阈值,就挂到这条轨迹上;否则认为这是一个新出现的目标,新建轨迹。每条轨迹的lost计数用于判断目标是否已经走出画面。
这里没有用卡尔曼滤波,目标被短暂遮挡时轨迹会断。如果监控画面中遮挡频繁,可以把预测步骤升级为“用上一帧框中心点做线性外推,再和外推框计算 IoU”,代码改动量不大,但轨迹连续性会明显改善。
3.3 参数调节:min_iou、max_lost、min_track_len 怎么定
这三个参数直接决定轨迹质量,我的经验值如下:
| 参数 | 推荐范围 | 作用 | 调高后果 | 调低后果 |
|---|---|---|---|---|
| iou_thresh | 0.3-0.5 | 判定两帧框是否属于同一人 | 轨迹易断,一人变多人 | 不同人容易串成一条轨迹 |
| max_lost | 10-25 | 目标消失多少帧后判定轨迹结束 | 轨迹尾迹变长,可能串接他人 | 短暂遮挡即断,轨迹碎片化 |
| min_track_len | 5-10 | 少于该帧数的轨迹直接丢弃 | 过滤掉误检,但丢真目标 | 产生大量噪声轨迹 |
min_track_len整个实现里很重要,但容易漏掉。单帧误检、飞鸟、光影变化都可能产生孤立的检测框,这些不会形成连续轨迹,直接通过长度过滤掉,检索阶段就少很多干扰。一般我会设置成 5 帧,如果视频是 25fps,这就是 0.2 秒的出现时长,低于这个值时认为目标只是路过,不值得记录。
完成轨迹生成后,把所有轨迹的包络框中心点连接起来,就能画出运动路径;同时记录每条轨迹的起止帧区间,这个区间就是后续检索要返回的“候选视频片段”。
4. 特征提取与检索排序:把轨迹变成能比对的向量
4.1 轨迹代表帧的选择:中线帧与峰值质量帧
轨迹有了,接下来要回答一个问题:拿什么去和目标图像比对?一条轨迹可能包含几十帧裁剪图,逐帧比对计算量太大,也没必要。常见做法是选代表帧。
两种选法:
第一种,取轨迹中段的帧。行人从画面一侧走到另一侧,中间位置通常姿态端正、遮挡最少,取中间第 50% 帧即可,实现简单。
第二种,取置信度最高的帧。SSD 检测时每帧都输出了置信度,选 conf 最大的那帧作为代表帧。这个方法更稳,因为检测置信度往往和目标的清晰度、完整度正相关。
def pick_representative_frames(track, top_k=3): sorted_boxes = sorted(track.boxes, key=lambda x: x[2], reverse=True) reps = [] for i in range(min(top_k, len(sorted_boxes))): frame_id, box, conf = sorted_boxes[i] reps.append(frame_id) return reps参数说明:top_k取 3 比较均衡。取 1 帧可能运气差选到目标抬手的瞬间,取 5 帧以上引入的噪声又大于收益。用 3 帧做平均特征,相当于在“快”和“稳”之间折中。
4.2 特征向量化:预训练 CNN 与余弦相似度
代表帧选出来以后,下一步是把裁剪的行人图像变成特征向量。这个项目环境里有 Keras 2.2.0,直接用预训练 MobileNet 提特征即可,不需要也不应该从头训练模型——监控视频里行人姿态千变万化,自己训练一个 ReID 模型需要大量标注数据,这里用预训练模型的泛化特征就够了。
from keras.applications.mobilenet import MobileNet, preprocess_input from keras.models import Model import numpy as np base_model = MobileNet(weights="imagenet", include_top=False, pooling="avg") feature_model = Model(inputs=base_model.input, outputs=base_model.output) def extract_feature(crop_img): img = cv2.resize(crop_img, (224, 224)) img = np.expand_dims(img, axis=0).astype("float32") img = preprocess_input(img) feat = feature_model.predict(img)[0] norm = np.linalg.norm(feat) return feat / (norm + 1e-6)逻辑说明:pooling="avg"让 MobileNet 输出的不再是特征图,而是一个 1024 维(原版 MobileNet 是 1024 维)的全局平均池化向量。最后做 L2 归一化是关键——归一化之后,余弦相似度就直接等于向量点积,计算量大减。
参数说明:weights="imagenet"是必须的,否则随机初始化的网络提取出的特征没有判别力。如果机器上没有提前下载好权重,第一次运行会联网下载约 16MB 的文件,放到~/.keras/models/下缓存,离线环境要手动拷贝。
轨迹级特征怎么合并?把同一轨迹 3 个代表帧的特征取平均再归一化:
def build_track_feature(track, crops): feats = [extract_feature(crop) for crop in crops] avg = np.mean(feats, axis=0) avg = avg / (np.linalg.norm(avg) + 1e-6) return avg平均操作能抹平单帧的姿态噪声,这是轨迹检索优于单帧检索的核心。单帧比对时,目标恰好低头或转身会导致相似度暴跌;轨迹平均后,只要大部分帧是正面的,特征就稳定。
4.3 检索闭环:TopK 片段输出与可视化标记
所有轨迹特征入库之后,检索就是一个排序问题。目标图像输入,提取同维特征,与所有轨迹向量算点积,按相似度降序返回 TopK 轨迹,每条轨迹对应一段起止时间。
def search(query_feat, track_features, top_k=5): scores = [] for track_id, feat in track_features.items(): sim = float(np.dot(query_feat, feat)) scores.append((track_id, sim)) scores.sort(key=lambda x: x[1], reverse=True) return scores[:top_k]检索结果可视化是完整闭环里不能省的一步。拿到 TopK 轨迹后,从原视频里把轨迹起止帧的区间截出来,把每个检测框重新画到帧上,合成一个标注过的视频片段。OpenCV 的VideoWriter可以完成这个工作,注意输出视频的编码参数用cv2.VideoWriter_fourcc(*"mp4v"),在大多数播放器里都能正常打开。
相似度阈值的选择直接决定误报率。我的判断基准是:相似度大于 0.75 基本是同一个人;0.65-0.75 之间是疑似,建议人工确认;低于 0.6 基本可以放弃。这个阈值和摄像头角度、行人身高比例关系很大,每换一个场景要做一次校准。
5. 避坑手记:从环境冲突到轨迹断链的四条血泪经验
5.1 现象:import keras 直接报段错误,或者训练时提示找不到tf.contrib
原因:TensorFlow 1.6 和 Keras 2.2.0 的搭配其实很微妙,Keras 调用的是 TF 的底层接口,版本升级后某些符号被移除,import 时不会报错,但一跑模型就抛 AttributeError。TensorFlow 1.6 还自带 contrib 模块,2.x 中完全移除了。
解决:锁死版本组合。Python 3.6.2 + TF-GPU 1.6.0 + Keras 2.2.0 这套组合是作者验证过的,不要轻易动任何一项。如果你是用 pip 安装的,建议pip install tensorflow-gpu==1.6.0 keras==2.2.0强制覆盖版本;如果之前装过更高版本的 Keras,先pip uninstall keras再装。还有一个常见做法是禁用 GPU、只用 CPU 调通逻辑,CUDA_VISIBLE_DEVICES=""加上即可,注意 TF 1.6 的 CPU 推理速度会慢三倍以上。
5.2 现象:readNetFromCaffe 报错,或者模型加载成功但检测框全部跑到图像边缘
原因:资源包里只有 caffemodel 没有 prototxt。readNetFromCaffe必须同时传入网络结构和权重,只给权重文件,OpenCV 不知道网络长什么样。即使你从别处找来一个通用的 prototxt,如果输入尺寸、均值参数对不上,检测框的位置和尺度也会乱掉。
解决:从 OpenCV 官方仓库下载对应 res10_300x300_ssd 的 deploy.prototxt,这个模型的网络结构是公开标准的,不存在版本兼容问题。加载时注意blobFromImage的参数必须和 prototxt 里的输入维度完全一致,用 300x300 输入,均值用 (104, 177, 123)。如果你手头换成了 512x512 输入的 SSD,这三个参数也要同步修改。
5.3 现象:两条轨迹各自完整,但其实是同一个人,中间被路灯杆切断
原因:这是 IoU 跟踪的死穴。目标从路灯杆后面走过时,检测框短暂消失超过 max_lost 帧,跟踪器判定轨迹结束;目标重新出现时,被当成新人开了新轨迹。画面越复杂,这种断链越多。
解决:提高 max_lost 到 20-25 帧,同时缩小 IoU 阈值到 0.3,让轨迹更“粘”。如果要彻底解决,就需要引入外观特征做轨迹重连——即把轨迹末尾帧和所有活跃轨迹开头帧的特征做一次相似度比对,相似度超过 0.8 就合并。这相当于拿检索模块反向加强跟踪模块,是一个典型的闭环优化思路。我在实际项目中把轨迹合并这个步骤加进去后,有效轨迹数减少了约 30%,检索结果干净很多。
5.4 现象:程序一运行,GPU 显存瞬间占满,跑几段视频直接 OOM
原因:TensorFlow 默认申请全部可用显存,不留给同机其它程序。监控视频分析往往同时跑多个进程,显存分配是冲突高发区。
解决:在创建 Keras session 之前加显存增长配置:
import tensorflow as tf import keras.backend as K config = tf.ConfigProto() config.gpu_options.allow_growth = True sess = tf.Session(config=config) K.set_session(sess)allow_growth = True表示按需分配显存,初始占用很小,随模型变大逐步增长。这样即使同时跑两三个分析任务,显存也能挤得下。
6. 无标注数据下的效果验证:用时间折叠自证检索精度
没有标注数据,怎么证明检索系统真的有效?我常用的招数叫时间折叠。拿一段行人来回走动的监控视频,按时间切成两半:前半段作为 query 来源,后半段作为搜索库。视频里同一个人的外观基本不变,前半段截一张目标图,后半段如果还能搜到这个人,说明检索链路是通的。
具体操作:从前半段手工框一个行人作为 query,去后半段搜索 TopK。如果该行人确实在后半段出现过,它应该出现在结果里。统计多次查询的命中率,就能得出系统的有效精度。下面这个脚本可以自动算 Precision@K:
def precision_at_k(query_feat, track_features, ground_truth_id, k=5): scores = search(query_feat, track_features, top_k=k) hit = any([track_id == ground_truth_id for track_id, sim in scores]) return 1.0 if hit else 0.0跑验证时注意一个细节:query 目标和后半段目标的轨迹 ID 可能不一样,你需要先用第 5.3 节的轨迹合并逻辑或者手工标注,建立起前后两段的对应关系。否则会出现“明明搜到了人,但因为 ID 不同被判失败”的假阴性。
从那以后,我每换一个摄像头、每调一次相似度阈值,都会强制走一遍时间折叠验证。这个过程花不了十分钟,但它能暴露出检测漏检、轨迹断链、特征漂移一系列问题——有一次我调整了检测置信度阈值后检索率莫名下降,就是这个验证过程帮我定位到是低置信度轨迹反而带了更多噪声特征。这套自证流程已经成了我的习惯,也希望帮到你。
本文还有配套的精品资源,点击获取