简介:基于HOG特征与SVM分类器的行人检测完整工程,适合计算机、人工智能、电子信息等专业学生用于毕设、课设或算法入门实践。项目包含图像裁剪、特征提取与模型训练、难例挖掘等完整流程,并提供最终检测测试代码,代码结构清晰,便于二次开发。资源共19个文件,以cpp源码、jpg测试图片、txt样本列表及说明文档为主,压缩包约626KB,轻量易用。已有161人浏览下载,配套README可辅助快速上手,遇到运行问题也可联系作者获得远程指导。
1. 行人检测项目:HOG+SVM从训练到检测的完整闭环
一个下午,我拿到一份标注好的行人样本集,想复现经典的行人检测流程,结果跑了三遍都训练不出稳定模型。后来我发现问题不在样本,而在hard negative挖掘和SVM转HOGDetector的细节上。这份基于HOG和SVM的行人检测项目,就是一套从样本裁剪、特征提取、SVM训练到难例挖掘、最终模型输出的完整C++/OpenCV实现,附带详细文档说明。它适合正在做人工智能、计算机视觉相关毕设或课程设计的人,也适合想搞懂行人检测整个训练闭环的入门开发者。你不需要自己从头搭框架,按步骤跑完就能拿到检测模型,还能在代码基础上替换数据集、调整参数做二次开发。
2. 项目架构与数据准备:从样本裁剪到目录结构
2.1 目录结构与文件职责
打开压缩包,第一眼看到的是Pedestrain_Detection-master这个根目录,里面除了CMakeLists.txt之外,还有一堆cpp文件和若干txt清单文件。我习惯先看CMakeLists.txt,因为这种项目十有八九是靠CMake组织的——它决定了源码怎么编译成可执行文件。这个文件里把几个cpp分别编成了对应的可执行目标:crop_image、detection、find_save_hardexample、test_main。也就是说,整个训练流程被拆成了四个独立工具,前端界面和依赖关系都不复杂,适合逐步调试。
具体文件职责大致是这样:
crop_image.cpp:裁剪负样本图片,把大图中不含行人的区域切成固定大小的训练样本;detection.cpp:训练主程序,负责提取HOG特征、训练SVM、保存模型;find_save_hardexample.cpp:难例挖掘工具,用当前模型在负样本图上找误检并保存;test_main.cpp:检测演示程序,加载模型对测试图做检测并画框;detection.h:公共头文件,主要声明了HOG参数和工具函数;pos1.txt/sample_neg.txt/sum_pos.txt:样本清单文件,分别记录了正样本、负样本的路径和标注信息;img_dir、data、normalized_images、train、test:图片数据集或中间产物目录;Test2.jpg、Test3.jpg、Test4.jpg、Test5.jpg:测试图片,用来验证最终模型效果;README.md:文档说明,里面应该有每个步骤的具体命令和参数。
这里有个小提示:train和test目录看起来是分开的,但作者在训练时并没有完全依赖目录自动读取,而是用txt清单来控制样本列表。这种设计的好处是——你新增样本时不需要改代码,只要往txt里追加一行路径就行。
2.2 用crop_image.cpp裁剪负样本:参数与运行方式
负样本不是直接整张图丢进训练集,而是需要切成与正样本同样大小的图片块。如果正样本是64x128,负样本也必须是64x128,这样提取出来的HOG特征维度才一致。crop_image.cpp干的就是这件事:读取一张大图,用滑动窗口或者随机位置切出若干块,保存到输出目录。
核心逻辑通常是这样的:
// crop_image.cpp 关键片段 #include <opencv2/opencv.hpp> #include <fstream> #include <iostream> using namespace cv; using namespace std; int main(int argc, char** argv) { // 输入参数:负样本清单、输出目录 if (argc < 3) { cout << "Usage: crop_image <sample_neg.txt> <output_dir>" << endl; return -1; } ifstream fin(argv[1]); string imgPath; int count = 0; Size cropSize(64, 128); // 与正样本保持一致 while (fin >> imgPath) { Mat img = imread(imgPath); if (img.empty()) { cerr << "无法读取: " << imgPath << endl; continue; } // 在整张图上按步长滑动裁剪负样本 for (int y = 0; y + cropSize.height <= img.rows; y += 32) { for (int x = 0; x + cropSize.width <= img.cols; x += 32) { Mat roi = img(Rect(x, y, cropSize.width, cropSize.height)); char outName[256]; snprintf(outName, sizeof(outName), "%s/neg_%06d.jpg", argv[2], count++); imwrite(outName, roi); } } } cout << "裁剪完成,共生成 " << count << " 张负样本" << endl; return 0; }这段代码有几个关键参数值得留意:裁剪窗口大小cropSize(64, 128)必须和后续HOG检测窗口一致,否则训练和检测对不上;滑动步长32决定了负样本的数量和重叠程度——步长越小、样本越多,但也会引入大量高度相似的重复样本,训练时间变长。我一般会把步长设成窗口宽高的一半,也就是64x128的窗口用32x32步长,既能保证覆盖密度,又不会让样本数爆炸。如果某些负样本图特别大,比如1920x1080,一张图就能切出几百个小块,这时候要注意观察生成数量,太多的话可以调大步长。
2.3 样本文件格式:pos1.txt与sample_neg.txt怎么读
训练程序不是自己遍历目录找图片的,而是读txt文件。pos1.txt记录的是正样本路径和标注框,常见格式是每行一个样本:
img_dir/person_001.jpg 0 0 64 128 img_dir/person_002.jpg 10 5 64 128这行数据表示图片路径、目标框左上角坐标(x, y)和宽高(w, h)。有的版本直接给裁剪好的正样本图片路径,不带坐标,读取时只需要imread整张图即可。sample_neg.txt则简单很多,每一行就是一张负样本图片的路径。sum_pos.txt从名字看像是统计正样本总数的文件,但也可能存的是汇总后的正样本列表,打开看一眼就知道——如果里面是数字,就说明它是给训练代码做样本数量上限用的。
读取这些文件时最容易翻车的地方是路径分隔符。Windows下生成的txt往往用反斜杠\,而OpenCV的imread在Linux下认的是正斜杠/。我在复现时习惯写个小命令做全局替换,比如用sed把\换成/,或者在读取时对路径做字符串清洗。README里如果已经给出了生成清单的方式,最好是直接按它来,避免自己手工维护txt导致的格式错误。
3. 核心训练流程:detection.cpp中的HOG特征提取与SVM训练
3.1 特征提取参数:winSize、blockSize、cellSize怎么配
detection.cpp是整套训练的核心,打开它最先看到的就是一堆HOG参数。OpenCV的HOGDescriptor构造函数有四个关键参数,直接决定特征向量的维度和表达能力:
// detection.cpp 关键片段 cv::HOGDescriptor hog( cv::Size(64, 128), // winSize:检测窗口大小 cv::Size(16, 16), // blockSize:块大小 cv::Size(8, 8), // blockStride:块滑动步长 cv::Size(8, 8), // cellSize:细胞单元大小 9 // nbins:梯度方向直方图通道数 );这套参数是行人检测的经典配置:窗口64x128,块16x16,块步长8x8,单元格8x8,直方图9个bin。这样每个块包含四个cell,每个cell有9维特征,一个窗口内横向有7个块、纵向有15个块,最终特征维度是7×15×36=3780维。这个数字在训练时很有用,你可以拿它校验特征矩阵的列数,如果算出来不对,说明参数没对上。
我一般不太建议乱改这些参数。block越小、cell越细,特征维度越高,训练越慢,而且小尺度的纹理噪声会被放大;block太大又会丢失局部梯度细节。如果你要检测的不是行人而是猫狗,可以保留这套参数只换训练数据,效果通常也不会太差。
3.2 SVM训练与模型序列化
特征提取完之后,就是经典的SVM训练。OpenCV 3/4里SVM接口用的是cv::ml::SVM,训练数据是一个Mat,每行是一个样本的特征向量,标签是Mat,正样本标1、负样本标-1,像我下面这样组织:
// 训练数据组织 cv::Mat featureMat(positiveCount + negativeCount, hog.getDescriptorSize(), CV_32FC1); cv::Mat labelMat(positiveCount + negativeCount, 1, CV_32SC1); // 填充特征矩阵和标签矩阵... // 假设已经把所有样本的HOG特征按行写入featureMat // 创建并训练SVM cv::Ptr<cv::ml::SVM> svm = cv::ml::SVM::create(); svm->setType(cv::ml::SVM::C_SVC); svm->setKernel(cv::ml::SVM::LINEAR); svm->setC(0.01); svm->train(featureMat, cv::ml::ROW_SAMPLE, labelMat); // 保存SVM模型 svm->save("svm_model.xml");这里有两个容易踩的坑。第一,特征矩阵类型必须是CV_32FC1,不能是CV_64FC1,OpenCV的HOG提取结果默认就是float,但如果你用其他方式拼特征,记得转类型。第二,SVM类型最好用C_SVC加线性核,不要用RBF核。行人检测场景特征维度高,线性核训练快、泛化稳定,RBF虽然理论上拟合更强,但在这个任务上容易过拟合,而且预测速度慢,不划算。C值是正则化参数,0.01是很多人调出来的经验值,C越大对误分类惩罚越重,模型越复杂,但未必泛化更好。
3.3 从SVM到HOGDetector的转换细节
模型训练完,下一步是把SVM模型转成HOG可以直接用的detector向量。这一步是整个项目里最容易让人懵的地方——很多人训练完SVM直接保存,又不知道怎么用在检测上,其实HOG检测器用的不是SVM的原始模型,而是支持向量和偏移量的组合。
// 将SVM支持向量和rho转换为HOG检测器 std::vector<float> detector; cv::Mat sv = svm->getSupportVectors(); detector.clear(); // 支持向量是线性可分的,直接把支持向量作为检测器系数 for (int i = 0; i < sv.rows; i++) { const float* p = sv.ptr<float>(i); for (int j = 0; j < sv.cols; j++) { detector.push_back(p[j]); } } // 在末尾追加rho(决策函数的偏置),注意OpenCV的HOGDescriptor要求rho放在最后 detector.push_back(svm->getDecisionFunction(0).second); // 把detector设置给HOG,并保存为OpenCV格式的检测器文件 hog.setSVMDetector(detector); hog.save("hog_detector.xml");为什么不能直接svm->predict做检测?因为HOGDescriptor::detectMultiScale内部是用一个线性分类器来打分的,它只认一个detector向量,不支持直接调用SVM对象。把支持向量和rho拼进去之后,HOG在滑动窗口时用这个向量做点积,等价于一次线性SVM的决策。保存成hog_detector.xml之后,检测程序只用这一份文件就能加载模型,不再依赖SVM模型文件。
操作时需要注意:getDecisionFunction返回的rho是一个double,要转成float再push进去;如果有多分类SVM,这个函数返回的是多组决策函数,但行人检测是二分类,直接取第0组即可。转换完成后可以打印一下detector.size(),应该等于特征维度加1,也就是3781,这个数字能帮你确认拼接没错。
4. 难例挖掘:find_save_hardexample.cpp的迭代训练
4.1 什么是hard negative,为什么需要它
如果你只用负样本清单里的那些随机图片块训练SVM,得到的模型往往在真实场景下误检率特别高。原因是:随机裁剪的负样本大多是均匀背景、路面、天空这类“简单”负样本,模型没有见过“长得像行人但实际不是”的难例,比如电线杆、路牌、树干的影子。难例挖掘就是拿当前模型去大图上扫一遍,凡是打分超过阈值但实际是背景的窗口,把它截下来当负样本再训练一轮。这样模型被这些“硬样本”反复捶打之后,误检会明显下降。
find_save_hardexample.cpp的作用就是在已经训练出初步模型之后,用这个模型扫描负样本大图,把误检的窗口保存成hard example。
4.2 难例挖掘的代码逻辑
难例挖掘程序的核心是复用已经训练好的HOG检测器,在负样本图上做多尺度检测。实现上通常会开一个循环,对每张负样本图调用detectMultiScale,然后把所有返回的矩形都当成难例,不管它是不是真的检测到了“人”。
// find_save_hardexample.cpp 关键片段 cv::HOGDescriptor hog; hog.load("hog_detector.xml"); // 加载初步训练的检测器 std::vector<cv::Rect> found; std::vector<double> weights; // 对负样本大图进行多尺度检测 hog.detectMultiScale(negImg, found, weights, 0.0, cv::Size(8, 8), cv::Size(0, 0), 1.05, 2.0, false); // 将检测到的窗口视为hard negative,裁剪保存 for (size_t i = 0; i < found.size(); i++) { if (found[i].width >= 32 && found[i].height >= 64) { cv::Mat hard = negImg(found[i]); char name[256]; snprintf(name, sizeof(name), "hard/hard_%06d.jpg", hardCount++); cv::imwrite(name, hard); } }这里detectMultiScale的参数值得说明:hitThreshold设置成0.0,意味着只要SVM打分大于等于0就算检出,这是为了尽量多抓误检;winStride设成8x8,比训练时的块步长更密,能覆盖更多候选位置;scale设成1.05,金字塔每层缩小5%,尺度细腻但计算量更大。保存时我一般会过滤掉太小的窗口,因为小于训练窗口的候选框即使保留下来,下一轮训练时也容易被resize拉伸变形,反而引入噪声。
跑完一轮难例挖掘,你会得到几百到几千张hard example图片。这些图片不用手动筛选,全部直接追加到负样本清单里重新训练。
4.3 把难例合并进训练集再训练
合并操作不需要写代码,在命令行里简单追加即可:
# 把hard example路径追加到负样本清单 find hard/ -name "*.jpg" >> sample_neg.txt # 统计当前负样本数量,确认没有重复追加 wc -l sample_neg.txt然后重新运行detection.cpp训练。这一步的要点是:每次难例挖掘生成的新样本,都要追加到sample_neg.txt,而不是替换掉旧的负样本。因为旧负样本承担的是“基础背景”的识别,新负样本承担的是“难例”的识别,两者都需要。如果负样本数量变得特别大,比如好几万张,训练时间会明显拉长,我一般会控制在正样本数量的3到5倍左右,超出时对hard example做随机采样,保留一部分就够。
难例挖掘通常迭代两到三轮。第一轮挖出的难例最多,效果提升最明显;第二轮之后新增的难例会变少,说明模型已经逐步收敛。如果你跑完第二轮发现hard example数量还是很多,不要急着加第三轮,先检查是不是检测阈值设得太低或者样本标注有问题。
5. 避坑与常见问题:训练不收敛、误检率高的排查笔记
5.1 现象:训练完成后模型在测试集上准确率很差,漏检严重
原因:正负样本比例失衡,或者正样本没有统一尺寸。负样本太多、正样本太少,SVM会偏向把一切判为负;很多人直接用原始图片做正样本,尺寸从32x64到128x256都有,HOG提取出的特征维度对不上,训练程序要么报错,要么隐性丢弃部分样本。
解决:先检查pos1.txt里的每张图片是否都已经裁剪成64x128并统一缩放。crop_image只负责裁负样本,正样本一般在做标注时就应该裁好。如果正样本尺寸不统一,写一个循环把所有正样本resize到64x128再更新路径。训练时正负样本比例建议控制在1:2到1:5之间,太少就多裁一些负样本补齐。
5.2 现象:检测结果里框非常多,一个行人被框了好几遍,背景也被框出来
原因:没有做NMS,或者hard negative挖掘轮数不够。detectMultiScale本身在多尺度下会产生大量重叠检测框,如果直接绘制所有矩形,画面会全是框;背景被框出来则说明模型对难例还没学够。
解决:先用cv::groupRectangles合并重叠框,参数groupThreshold设为1.0到2.0之间,低于阈值的孤立框直接丢弃。如果合并后还是误检,就说明需要再跑一轮难例挖掘,不要靠调阈值硬压——硬压阈值会把真阳性也丢掉。
5.3 现象:运行find_save_hardexample时程序崩溃,或者内存疯涨
原因:负样本大图尺寸过大,detectMultiScale默认会构建图像金字塔,在1920x1080甚至4K图上做全尺度扫描,内存占用轻松上GB。如果还开了多线程,局部变量没有释放,就更容易撑爆。
解决:在难例挖掘前把大图先缩放到长边不超过800像素,既减少计算量,也不会显著影响难例质量。另外把scale从1.05改成1.1,金字塔层数变少,内存占用直接减半。程序仍崩溃的话,检查是不是HOG窗口比图片还大,OpenCV检测窗口大于输入图像尺寸时在某些版本会直接越界。
5.4 现象:加载模型时hog.load("hog_detector.xml")失败,或者运行时检测结果全是乱的
原因:SVM模型和HOG检测器是两个不同格式的文件。svm->save()得到的xml里是支持向量、alpha、rho这些SVM内部结构,HOGDescriptor::load不认识它;反过来,如果你把HOG检测器xml交给svm->load,一样会失败。
解决:明确两个文件的用途——svm_model.xml只用来做二次训练或调参,检测阶段加载的是hog_detector.xml。如果手头只有svm模型,按照第3.3节的方法先转换出detector向量再保存。加载成功后建议做一个冒烟测试:用测试图跑一次,看框是否出现在行人附近,而不是满屏乱飘。
6. 二次开发:把模型接到test_main.cpp里做实时检测与参数调优
6.1 替换检测模型与调整检测阈值
test_main.cpp的作用很简单:加载hog_detector.xml,对Test2.jpg、Test3.jpg等图片运行检测,把结果画框保存。要换自己的模型,只需要改一行路径。真正需要调的是detectMultiScale的hitThreshold参数,它代表SVM决策函数的打分阈值,默认0.0。调低这个值会让模型更敏感,能召回更多行人,但误检也会变多;调高则相反。我一般先用0.0跑一遍,观察误检情况,再以0.1为步长往上调,直到误检和漏检达到平衡。
6.2 增加NMS非极大值抑制
原始检测结果直接画框会非常乱,detectMultiScale返回的found列表里重叠框太多。标准做法是用groupRectangles做合并:
// test_main.cpp 检测主循环关键片段 std::vector<cv::Rect> found, rejected; cv::groupRectangles(found, rejected, 1, 0.2); for (size_t i = 0; i < found.size(); i++) { cv::rectangle(img, found[i], cv::Scalar(0, 0, 255), 2); }groupThreshold = 1表示至少两个框重叠才合并,eps = 0.2控制合并时的矩形相似度,值越小合并后的框越接近原始框。如果检测框还是太大太小,可以在detectMultiScale返回后按矩形宽高过滤,只保留高在80到400像素之间的候选框,能去掉不少因尺度异常产生的误检。
6.3 从C++迁移到Python的改造思路
虽然这份项目源码是C++,但如果你平时用Python更多,把整个流程移植到OpenCV Python并不难。核心API一一对应,逻辑完全一样:
import cv2 hog = cv2.HOGDescriptor((64, 128), (16, 16), (8, 8), (8, 8), 9) # 训练阶段 svm = cv2.ml.SVM_create() svm.setType(cv2.ml.SVM_C_SVC) svm.setKernel(cv2.ml.SVM_LINEAR) svm.setC(0.01) svm.train(featureMat, cv2.ml.ROW_SAMPLE, labelMat) # 转换为detector detector = svm.getSupportVectors()[0].tolist() detector.append(-svm.getDecisionFunction(0)[0]) # 注意rho的符号 hog.setSVMDetector(detector) hog.save("hog_detector.xml")注意Python版本里getDecisionFunction返回的rho要取负值,这个符号差异在不同OpenCV版本里坑过我一次。转换好之后,用hog.detectMultiScale检测,后续画框、合并操作和C++一致。如果你想直接跳过训练,OpenCV还提供了cv2.HOGDescriptor_getDefaultPeopleDetector(),但那是训练好的通用模型,换成自己的数据集时还是要重新训练。
6.4 验证方法:用Test2.jpg等图片跑一遍
改动代码后怎么确认没改坏?我的做法是固定四张测试图,跑完后人工检查三件事:检测框数量是否合理、行人是否都被框住、背景误检是否控制在可接受范围。每次调参只改一个变量,记录下当前阈值下的检测框数量和误检数,做成一个简单对照表。比如把hitThreshold从0.0调到0.2,检测框数从57降到19,误检明显减少,但有一张测试图里的行人漏检了,那就回到0.1再试。这样一轮一轮调,比凭感觉乱改靠谱得多。
从那以后,我每次训练行人检测模型都强制走一遍“训练→难例挖掘→再训练”的完整迭代,测试阶段也固定按阈值、NMS、尺度三个维度分别验证。这样的习惯帮我少踩了很多次调参翻车的坑,也希望帮到你。
本文还有配套的精品资源,点击获取